From 51ab7adcb426d3a2dc10a1fda7f0490ae07d9a77 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 10 Sep 2026 00:50:59 -0700 Subject: [PATCH 1/2] fix(search): clarify Atlassian setup and validate Confluence permissions --- .../atlassian-service-account.mdx | 212 ++++++----------- apps/docs/content/docs/search/confluence.mdx | 168 +++++-------- apps/docs/content/docs/search/jira.mdx | 111 +++------ .../integrations/connect-account-options.tsx | 7 +- .../integrations/integrations.test.tsx | 38 ++- .../integrations/integrations.tsx | 5 +- .../[connectorId]/source-detail.test.tsx | 17 ++ .../sources/[connectorId]/source-detail.tsx | 9 + .../connect-service-account-modal.test.tsx | 48 +++- .../connect-service-account-modal.tsx | 17 +- .../add-connector-modal.test.tsx | 1 + .../add-connector-modal.tsx | 10 + .../connector-settings-fields.test.tsx | 4 + .../connector-settings-fields.tsx | 15 +- apps/sim/connectors/confluence/confluence.ts | 32 ++- apps/sim/connectors/confluence/permissions.ts | 146 +++++++++++- .../connectors/confluence/preflight.test.ts | 225 ++++++++++++++++++ apps/sim/hooks/use-member-enrollment.test.tsx | 81 ++++++- apps/sim/hooks/use-member-enrollment.ts | 6 + 19 files changed, 790 insertions(+), 362 deletions(-) create mode 100644 apps/sim/connectors/confluence/preflight.test.ts diff --git a/apps/docs/content/docs/integrations/atlassian-service-account.mdx b/apps/docs/content/docs/integrations/atlassian-service-account.mdx index 4beb807c500..b0dbd6d5b3c 100644 --- a/apps/docs/content/docs/integrations/atlassian-service-account.mdx +++ b/apps/docs/content/docs/integrations/atlassian-service-account.mdx @@ -1,177 +1,101 @@ --- title: Atlassian Service Accounts -description: Set up an Atlassian service account with a scoped API token to use Jira, Jira Service Management, and Confluence in Sim workflows +description: Connect Jira, Jira Service Management, and Confluence workflows with a scoped service-account API token --- import { Callout } from 'fumadocs-ui/components/callout' -import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' -import { FAQ } from '@/components/ui/faq' - -Use an Atlassian service account with a scoped API token to connect Jira, Jira Service Management, and Confluence. Grant the account access to the products, projects, and spaces your workflows need. +Use an Atlassian service account for Jira, Jira Service Management, and Confluence workflows. One credential can serve all three products on the same site when its account access and token scopes cover each product. + +Setting up Search? Follow the [Confluence Search service-account guide](/search/confluence#using-a-service-account) for its content and permission scopes. [Jira Search](/search/jira) uses each teammate's OAuth account; a workflow service account does not replace that connection. + -One service account covers all three products. You add it once, and it appears as a connected credential on the Jira, Jira Service Management, and Confluence integration pages alike — there is no separate credential to create per product. +## Create the account and token -## Prerequisites +An **Atlassian organization admin** completes these steps: -You need an Atlassian organization admin to create the service account. Service accounts are an Atlassian organization-level feature — they cannot be created from a regular user account. +1. Open [Atlassian Administration](https://admin.atlassian.com/), select the organization, then **Directory → Service accounts → Create service account**. +2. Give the account access to the intended site's Jira and/or Confluence apps. Grant the project and space permissions its workflows need, including access to restricted content. +3. Select the service account, then **Create credentials → API token → Next**. +4. Name the token, set an expiry between 1 and 365 days, and select **Next**. +5. Select the scopes below for the products and operations you need. Review and create the token, then copy it. Atlassian shows it only once. -## Setting Up the Service Account +See Atlassian's [service-account setup](https://support.atlassian.com/user-management/docs/manage-your-service-accounts/) and [token instructions](https://support.atlassian.com/user-management/docs/manage-api-tokens-for-service-accounts/). -### 1. Create the Service Account +Atlassian Administration credential selector with API token selected - - - Open [admin.atlassian.com](https://admin.atlassian.com/) and go to **Directory** → **Service accounts** +## Choose scopes - {/* TODO(screenshot): admin.atlassian.com directory page with the "Service accounts" tab highlighted */} - - - Click **Create service account**, give it a name (e.g. `sim-jira-bot`), and finish creation - - - Grant the service account access to the Atlassian sites and products it needs. Open the service account, go to **Product access**, and add Jira and/or Confluence on the relevant site +Start with the connection and read scopes for each product you will use. These cover Sim's account validation, pickers, and common read operations; individual operations may need additional scopes. - {/* TODO(screenshot): service account "Product access" tab showing Jira granted on a site */} - - +### Jira and Jira Service Management - -The service account inherits permissions from the project/space roles you grant it — exactly like a human user. If a workflow needs to write to a specific Jira project, give the service account write access to that project in Jira's project settings. - +```text +read:jira-user +read:jira-work +``` -### 2. Create a Scoped API Token - - - - From the service account's page in admin.atlassian.com, open the **API tokens** tab and click **Create API token** - - {/* TODO(screenshot): service account API tokens tab with "Create API token" button */} - - - Choose **API token** as the authentication type (not OAuth 2.0 — Sim uses the API token flow) - -
- Atlassian admin — Choose authentication type with API token selected -
-
- - Select the scopes the token needs. The minimum set Sim's Jira and Confluence blocks expect is: - - **Jira (classic):** - ``` - read:jira-user - read:jira-work - write:jira-work - ``` - - **Jira Service Management (classic):** - ``` - read:servicedesk-request - write:servicedesk-request - manage:servicedesk-customer - ``` - - **Confluence (classic and granular):** - ``` - read:confluence-content.all - read:confluence-space.summary - write:confluence-content - read:page:confluence - write:page:confluence - ``` - - Add more scopes only if you need the corresponding operations (delete, manage webhooks, etc.). The full list of scopes Sim's blocks may use is documented in [Atlassian's developer reference](https://developer.atlassian.com/cloud/jira/platform/scopes-for-oauth-2-3LO-and-forge-apps/). - - - Prefer the classic scopes above over granular equivalents. Atlassian enforces an endpoint's granular scope list as all-or-nothing, so a token built from a partial granular set fails with `Unauthorized; scope does not match` even though each individual scope was granted. The classic scopes each cover their product's endpoints on their own. If your organization only permits granular scopes, include every scope listed for each endpoint in Atlassian's reference — Jira Service Management request operations also require `read:user:jira`. - - -
- Atlassian token scope picker filtered to App: Jira and Scope type: Classic -
- - - Use the **App** and **Scope type** filters to narrow the list to the scopes you need. Filter by `App: Jira` (or `Confluence`) and `Scope type: Classic` to find the three core Jira scopes; switch to **Granular** if your org doesn't expose Classic. - -
- - Copy the token when it is shown and record its expiration date in Atlassian Administration. Create a replacement before it expires; Sim does not refresh a pasted API token. - -
- - -The API token is bearer credentials for the service account. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest. - +`read:jira-user` covers the [current-user check](https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-myself/#api-rest-api-3-myself-get) when you add the credential, including through Jira Service Management. Add `read:servicedesk-request` for Service Management requests. -### 3. Find Your Site Domain +### Confluence -Enter only the host from your Jira or Confluence URL, such as `your-team.atlassian.net`; omit `https://` and any path. +```text +read:confluence-user +read:confluence-content.all +read:confluence-space.summary +read:space:confluence +read:page:confluence +``` -## Adding the Service Account to Sim +`read:confluence-user` covers the [current-user check](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-users/#api-wiki-rest-api-user-current-get). The [space picker](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/#api-spaces-get) needs `read:space:confluence`; page reads and the page picker need `read:page:confluence`. - - - Open **Integrations** in your workspace sidebar - - - Open **Jira**, **Jira Service Management**, or **Confluence** — any of the three works, since they share one service account +### Workflow actions - {/* TODO(screenshot): Integrations page with Jira in the list */} - - - Click **Add to Sim** and choose **Add service account** +Add scopes for the actions your workflow performs: - {/* TODO(screenshot): Jira integration page with the "Add to Sim" dropdown open */} - - - Paste the API token, enter the site domain (e.g. `your-team.atlassian.net`), and optionally set a display name and description +| Actions | Scopes to add | +| --- | --- | +| Create or update Jira issues | `write:jira-work` | +| Create or update Service Management requests | `write:servicedesk-request` | +| Manage Service Management customers | `manage:servicedesk-customer` | +| Create or update Confluence content | `write:confluence-content`, `write:page:confluence` | +Delete, webhook, Assets, and other operations can require additional scopes. Check the specific endpoint in the [Jira](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/), [Jira Service Management](https://developer.atlassian.com/cloud/jira/service-desk/rest/intro/), or [Confluence](https://developer.atlassian.com/cloud/confluence/rest/v2/intro/) API reference. Include the complete scope set for that endpoint; a classic scope does not cover every API in its product. - - - Click **Add service account**. Sim resolves the site and checks the token against the selected product's identity endpoint. Review any connection error before continuing. - - +Use the **App** and **Scope type** filters to find both classic and granular scopes. Scopes and account permissions are separate: the account must also have access to the project, space, or content. -The token, domain, and discovered cloudId are encrypted before being stored. +Atlassian scope picker filtered to Jira classic scopes -Once added, the credential is listed under **Connected** on all three Atlassian integration pages. It is named after the service account's own Atlassian display name, so several service accounts on the same site stay easy to tell apart. +## Add the credential to Sim -## Using the Service Account in Workflows +1. Open **Integrations** in your workspace sidebar, then **Jira**, **Jira Service Management**, or **Confluence**. +2. Select **Add to Sim → Add service account**. If only service accounts are available, select **Add service account** directly. +3. Paste the **API token** and enter **Site domain**, such as `your-team.atlassian.net`. Omit `https://` and any path. Optionally add a display name and description. +4. Select **Add service account**. Sim checks the token against the selected product's current-user endpoint; resolve any error before continuing. +5. In your workflow's Jira, Jira Service Management, or Confluence block, select the credential and configure the operation. -Add a Jira, Jira Service Management, or Confluence block to your workflow. In the credential dropdown, your Atlassian service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. +The credential appears on all three integration pages, but adding it only validates the selected product. Check account access and scopes before using another product. Sim encrypts the token at rest and calls Atlassian as the service account. -
- Jira block in a workflow with the Atlassian service account selected as the credential -
+## Troubleshooting and rotation -The block calls Atlassian's API gateway (`api.atlassian.com/ex/jira/{cloudId}/...`) using the service account's token. There's no impersonation step — the service account acts as itself, with whatever permissions you granted it in admin.atlassian.com. +| Problem | What to check | +| --- | --- | +| Cannot add the credential | Use a scoped API token from **Directory → Service accounts**, verify its expiry and site, and include the selected product's current-user scope above. | +| Empty or failed picker | Include `read:jira-work` for Jira projects or `read:space:confluence` for Confluence spaces, and grant the account access to the selected site and content. | +| A workflow returns a scope or permission error | Check the operation's full scope list and the account's project/space permissions. Successful connection does not validate every operation. | +| Token expires or needs different scopes | Create a replacement token, add it as a new Sim service-account credential, and select it in the affected workflows. Test them before revoking the old token. Sim does not refresh pasted API tokens. | +| Data Center or Server host | This credential supports Atlassian Cloud only. | - +For a Search source, follow [Confluence's indexing-account replacement steps](/search/confluence#using-a-service-account). diff --git a/apps/docs/content/docs/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx index d171730c160..dc78d10cb2c 100644 --- a/apps/docs/content/docs/search/confluence.mdx +++ b/apps/docs/content/docs/search/confluence.mdx @@ -7,31 +7,20 @@ 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 pages and blog posts from selected Confluence Cloud spaces. A Sim organization admin approves the provider; **each teammate connects their own Confluence account**, including when a service account supplies the content. -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 → Sources** 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 setup - -| Method | Who supplies the content? | What teammates do | -| --- | --- | --- | -| **Service account** | One service 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. | - -Selecting **Add source** on Confluence's integration page opens service-account setup. Use this when a service account can read the intended spaces and their permissions. For a member source, start **Connect** from **Integrations** in the main sidebar after an admin allows Confluence. 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. +| Method | How it works | +| --- | --- | +| **Service account** | One account indexes content and permissions. Each teammate connects to match their Atlassian identity to those permissions. | +| **Member accounts** | Sim indexes the pages each connected teammate can access, using that person's account. | ## 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. +- Use **Confluence Cloud** and a site hostname such as `your-team.atlassian.net`. Server and Data Center are not supported. - 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. +- For a central source, an Atlassian organization admin creates a service account with Confluence access. Grant it access to the chosen spaces and restricted pages, plus permission to read space permissions and the user/group directory. Admin status alone does not bypass page restrictions. -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. +On hosted Sim, teammates authorize Sim's existing OAuth app. Self-hosted deployments must configure the [shared OAuth app](#self-hosted-operator-setup), including when a service account supplies content. ## Set up a central source @@ -40,84 +29,51 @@ On hosted Sim, personal connections authorize the existing Sim app. Teammates do ### Choose Confluence -Open **Settings → Sources** and turn on **Confluence**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. **Setup guide** opens this guide from the source form. - - - - -### Select a service account - -Under **Indexing account**, select an existing service account or add one using the [steps below](#using-a-service-account). It must be able to read the content and its permissions. Personal OAuth accounts are used in the member connection flow. +Open your organization's **Settings → Sources**, turn on **Confluence**, then select **Set up** (or **Manage**) → **Add source**. This opens service-account setup. -### Select the spaces +### Choose the account and 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`. - -Open **More options** to change **Content Type**, **Filter by Label**, or **Metadata tags**. The default includes pages; choose **All content** for pages and blog posts. Leave the label filter empty unless you want a smaller scope. +Under **Indexing account**, select a service account or [add one](#using-a-service-account). Enter the same **Confluence Domain** as the credential, then choose **Spaces**. To enter comma-separated keys such as `ENG, PRODUCT`, use the switch beside the Spaces field. Confluence central setup requiring a service account, site domain, and spaces +Open **More options** for content type, labels, and metadata tags. The default is **Pages only**; choose **All content** to include blog posts. + -### Save and connect your identity +### Sync and connect your identity -Click **Connect & Sync**. Then open **Integrations** in the main sidebar, click **Connect** on the Confluence source, 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. +Select **Connect & Sync**. Setup checks available permission data; each document's access is verified during sync. Then open **Integrations** in the main sidebar and select **Connect** on the Confluence source. In the new tab, authorize the configured site using the Atlassian email matching your verified Sim email. -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. +Each teammate completes this identity connection. An existing authorized account may already be connected. Return to Integrations to check indexing status and your searchable document count. +For workspace Search, start from **Search → Add source**. Available methods depend on the enabled features. + ## Connect member accounts -After an admin allows Confluence, open **Integrations** in the main sidebar and select **Connect**. If there is no source yet, enter **Confluence Domain** and **Space Keys**, then select **Connect** and authorize your account. For another site or space scope, select **Connect** beside the Confluence row labeled **Connect a different site or content scope**. +After an admin allows Confluence, open **Integrations → Connect**. If no source exists, enter **Confluence Domain** and **Space Keys**, then connect your account. To use another site or space scope, choose the row labeled **Connect a different site or content scope**. -An admin can open **Settings → Sources**, select **Manage** beside **Confluence**, and open the source's **Settings** tab to adjust its filters. **Account for browsing** helps populate the space picker; it does not connect that account for Search. Manual space keys work without a browsing account. +Admins manage these sources under **Settings → Sources → Confluence → Manage**. An **Account for browsing** populates the space picker; it does not enroll that account for Search. Manual space keys work without a browsing account. ## 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 a **scoped API token** from an Atlassian service account: -Use these scopes for Confluence Search content and permission reads: +1. In [Atlassian Administration](https://admin.atlassian.com/), open **Directory → Service accounts**. Create or select the account and grant the Confluence access described above. +2. Select **Create credentials → API token → Next**. Name the token and choose an expiry between 1 and 365 days. +3. Add **all scopes below**. Use the **App: Confluence** and **Scope type** filters to find both classic and granular scopes. ```text read:confluence-content.all @@ -134,68 +90,64 @@ read:user:confluence read:group:confluence ``` - - - -### Add the token to Sim +These scopes cover Search's account check, pickers, content, permissions, and directory reads. `read:confluence-user` is needed for the [current-user check](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-users/#api-wiki-rest-api-user-current-get); `read:space:confluence` is needed for the [space picker](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/#api-spaces-get). Workflow write scopes are not needed for central Search. -Under **Indexing account**, choose the service-account connection action. 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. +4. Review and create the token, then copy it. Atlassian shows it only once. +5. In Sim's source form, use **Indexing account** to add a service account. Paste the **API token**, enter **Site domain** (hostname only), and select **Add service account**. Continue in the source form with the same domain. - - +Atlassian Administration credential selector with API token selected -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. Add the replacement service account in the source's **Settings**, then use **Change indexing account** to apply it. +See Atlassian's [account setup](https://support.atlassian.com/user-management/docs/manage-your-service-accounts/) and [token instructions](https://support.atlassian.com/user-management/docs/manage-api-tokens-for-service-accounts/). Scopes do not grant space or page access themselves. Before expiry, or when scopes must change, create a replacement token and add it as a new credential in the source's **Settings**. Select **Change indexing account**, verify a sync, then revoke the old token. -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. +Personal OAuth connections use Sim's shared Confluence app, which also requests permissions for workflow actions, including writes. Search reads content and permissions; it does not edit pages. -## Configuration +## Configuration and indexed content | 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. | -| **Metadata tags** | Optional labels, version, and last-modified tags. In the add-source form, these are under **More options**. | - -Search manages the schedule and hides item limits. Published/current content is indexed; archived and trashed content is excluded. +| **Confluence Domain** | Cloud hostname only, such as `your-team.atlassian.net`; omit page URLs and `/wiki`. | +| **Spaces / Space Keys** | Required spaces. The picker and manual input set the same scope. | +| **Content Type** | **Pages only** by default; **All content** includes pages and blog posts. | +| **Filter by Label** | Optional comma-separated labels; content can match any listed label. | +| **Metadata tags** | Labels, version, and last-modified tags. | -## Teammates and ongoing sync +Search manages the schedule and hides item limits. It indexes published/current content and each page's own text, including supported local callouts and code blocks. Archived content, comments, attachment contents, and expanded Include Page, Excerpt Include, or third-party macro output are excluded. Referenced pages can be indexed separately with their own permissions. -Existing organization members see the configured Confluence source and their own **Connect** 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. +## Manage access and sync -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. +Central sources combine space permissions, page and ancestor restrictions, and group membership. Member sources use each person's provider listing. Sim admin status does not grant access to all pages, and permission changes take effect after syncing and processing. -New content and permission changes require a sync and processing before Search reflects them. Open **Settings → Sources**, select **Manage** beside **Confluence**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. If your own account needs authorization again, use **Reconnect** in the main Integrations page. Where available, the provider's **Accounts → Request connections** sends account connection requests; these do not invite people to the Sim organization. +Open **Settings → Sources → Confluence → Manage**, then a source's **Documents**, **Settings**, or **Sync history**. Teammates use **Integrations** to connect or reconnect. Invite new people to the Sim organization through Members settings or SSO first; **Accounts → Request connections** requests a provider connection, not organization membership. ## Troubleshooting -| What you see | What to check | +| Problem | What to check | | --- | --- | | **Connect & Sync** is disabled | Select a service account, enter its site 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 a central source. See Atlassian's [content access guide](https://support.atlassian.com/confluence-cloud/docs/add-or-remove-page-restrictions/). +| Space picker is empty or fails | Check the domain, account's space access, and `read:space:confluence` scope. Manual space keys are also supported. | +| Service-account validation fails | Check token expiry, site, Confluence app access, and the full scope list above, including `read:confluence-user`. | +| Content syncs but Search is empty | Connect your personal Confluence identity. Check permission/directory sync errors and group-read scopes. | +| A restricted page is missing | Both your account and the crawling account need access to the page and its ancestors. | +| Embedded content is missing | Index the referenced page separately; remote macro output is excluded. | +| **Reconnect** or email mismatch | Authorize with the Atlassian account matching your verified Sim email and grant all requested permissions. | -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/). +Open a missing page as the affected teammate. **Share → General access** shows its restrictions; a space admin can also inspect **Space settings → Content → Restricted**. On Premium, **Inspect permissions → Can view** helps locate denied access. Resolve the restriction, then sync again. See Atlassian's [content access](https://support.atlassian.com/confluence-cloud/docs/add-or-remove-page-restrictions/) and [permission inspection](https://support.atlassian.com/confluence-cloud/docs/inspect-a-users-permissions/) guides. ## Self-hosted operator setup -Configure one shared Confluence OAuth integration for your deployment. This powers personal connections in both Search methods. Central indexing uses a service-account token. +Configure one shared Confluence OAuth app for teammates' connections: -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. +1. In the [Atlassian developer console](https://developer.atlassian.com/console/myapps/), select or create the deployment's **OAuth 2.0 integration**. +2. Under **Authorization → OAuth 2.0 (3LO)**, save `https:///api/auth/oauth2/callback/confluence` as a callback, preserving callbacks used by other deployments. +3. Under **Permissions**, add the Confluence API and its full `confluence` scope list from [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts), including `read:group:confluence`. Add **User Identity API → read:me**. Sim requests `offline_access` for refresh tokens; the service-account list above does not replace this 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 **Integrations** and select the configured site. After changing the deployment's OAuth client or requested scopes, an organization admin selects **Settings → Sources → More → Refresh connection settings**, then affected teammates reconnect. +5. Connect from **Integrations** and select the configured site. After changing the OAuth client or requested scopes, use **Settings → Sources → More → Refresh connection settings**, then have affected teammates reconnect. -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). +The callback must exactly match Sim's URL, including scheme, hostname, port, and path. For `http://localhost:3000`, register `http://localhost:3000/api/auth/oauth2/callback/confluence`. If only the app owner can connect, check **Distribution**. See the [Atlassian OAuth guide](https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/) and [Sim deployment reference](/platform/self-hosting/integrations-oauth). diff --git a/apps/docs/content/docs/search/jira.mdx b/apps/docs/content/docs/search/jira.mdx index 7306de72675..22e4f0fee9f 100644 --- a/apps/docs/content/docs/search/jira.mdx +++ b/apps/docs/content/docs/search/jira.mdx @@ -7,22 +7,20 @@ 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. +Search issue titles, descriptions, and metadata from selected **Jira Cloud** projects. An organization admin approves Jira and defines the source; **each teammate connects their own Jira account** to search 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 → Sources** 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**. +Jira Search uses **Member accounts**. Service accounts are supported for Jira workflows, but do not provide a central Jira Search crawl. Comments, attachment contents, dashboards, and saved filters are not indexed. ## 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. +- A **Sim organization admin** must approve Jira. The admin can configure the source, or the first member connection can supply its site and projects. +- Use a Cloud site hostname such as `your-team.atlassian.net`. Server and Data Center are not supported. +- Each person needs a verified Sim email matching their active Atlassian account's email, plus access to the selected site and projects. Jira's Browse Projects and issue security permissions determine the 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. +On hosted Sim, teammates authorize Sim's existing app. Self-hosted deployments 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. +Search uses `read:jira-work`, `read:me`, and `offline_access` for issues, identity, and refresh. Sim's shared Jira app also requests permissions for workflow actions, including writes and deletes. Review the consent screen before authorizing. ## Set up the source @@ -32,22 +30,20 @@ Sim uses its existing Jira OAuth integration. Search uses `read:jira-work` to re ### Choose Jira -Open **Settings → Sources** and turn on **Jira**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. Jira sources use member accounts. +Open your organization's **Settings → Sources**, turn on **Jira**, then select **Set up** (or **Manage**) → **Add source**. ### Choose the projects -Under **Account for browsing**, 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. +Enter **Jira Domain**. Under **Account for browsing**, select an account or choose **Connect Jira account**, then select **Projects**. To enter keys such as `ENG, SUPPORT` manually, use the switch beside Projects; this works without a browsing account. -**Account for browsing** only populates the project picker. It does not enroll you or share that account's issue access with teammates. +**The browsing account only populates the picker. It does not connect that account for Search or share its access with teammates.** Jira source setup with an account for browsing and required site and project fields @@ -55,89 +51,58 @@ If you already know the project keys, use the switch beside **Projects** to sele -### Create the source +### Save the source -Open **More options** for **JQL Filter** and **Metadata tags**. Leave JQL empty to include all accessible issues in the selected projects, or add a condition such as `status = "Done"`. +Under **More options**, optionally set a **JQL Filter**, such as `status = "Done"`, and choose **Metadata tags**. Leave JQL empty for all accessible issues in the selected projects. Enter conditions only; omit `ORDER BY` because Sim supplies sorting. -Click **Add source**. The source appears on Jira's **Sources** tab. Creating it does not authorize accounts or send invitations. +Select **Add source**. This saves the shared scope; it does not authorize accounts or send invitations. ### Connect your search account -Open **Integrations** in the main sidebar and click **Connect** on the Jira source. 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. +Open **Integrations** in the main sidebar and select **Connect** on the Jira source. In the new tab, authorize the configured site using the Atlassian email matching your verified Sim email. -Return to Integrations to see connection and indexing status. Each teammate follows this same step. A previously authorized account may already be connected. +Each teammate follows this step. An existing authorized account may already be connected. Return to Integrations to check indexing status and your searchable document count. -## 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. | -| **Metadata tags** | Optional issue type, status, priority, labels, assignee, and last-updated tags. In the add-source form, these are under **More options**. | +For workspace Search, start from **Search → Add source**; its source-creation button is **Create & Invite**. Search manages the sync schedule and hides item limits. -Search manages the sync schedule. Item limits and sync frequency are not setup decisions on this page. +## Manage the source -## Teammates and ongoing sync +Admins open **Settings → Sources → Jira → Manage**, then a source's **Documents**, **Settings**, or **Sync history**. Settings include the site, projects, JQL filter, and optional issue type, status, priority, labels, assignee, and last-updated tags. -Existing organization members see the same source configuration and their own **Connect**, **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. +Teammates share the configured scope and do not choose projects again. For another site or scope, add another source, or use **Connect** beside **Connect a different site or content scope** in Integrations. -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 main Integrations page reports documents searchable by the current viewer. Admins open **Settings → Sources**, select **Manage** beside **Jira**, then open the source for **Documents**, **Settings**, and **Sync history**. +Invite new teammates to the Sim organization through Members settings or SSO, then have them connect Jira. **Jira → Accounts → Request connections** requests a provider connection; it does not add people to the organization. -Use **Jira → Accounts → Request connections** to send Jira connection requests. These requests do not invite people into the Sim organization. For a different site or project scope, an admin can add another source; members can also select **Connect** beside the Jira row labeled **Connect a different site or content scope** in Integrations. +Sim checks Jira separately for each connected person. Content becomes searchable as indexing finishes; issue changes and lost access are reflected after later syncs. ## Troubleshooting -| What you see | What to do | +| Problem | What to check | | --- | --- | -| 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 same email as your verified Sim account. If switching accounts in Jira does not help, log out of Atlassian and sign in again before retrying **Connect**. | -| 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** 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 +| No source setup controls | Ask a Sim organization admin to approve Jira. | +| Projects are empty or disabled | Enter the correct domain, connect a browsing account with project access, or switch to manual keys. | +| Connected, but no issues | Check the authorized site, project access, issue security, and JQL. An admin's Jira access does not grant access to teammates. | +| Email mismatch | Use the Atlassian email matching your verified Sim email. If switching accounts fails, sign out of Atlassian and retry **Connect**. | +| **Reconnect** | Reauthorize and grant all requested permissions. A revoked grant or changed scope list can require a new connection. | +| Connection tab does not open | Allow pop-ups for Sim and retry. | +| Invalid callback URL | Ask the operator to check the app identified by `JIRA_CLIENT_ID`; its callback must exactly match Sim's `redirect_uri`. See operator setup below. | -Atlassian illustration from its [permissions tutorial](https://www.atlassian.com/software/jira/guides/permissions/tutorials). UI labels may vary by Jira version. +Open a missing issue in Jira using the connected account. For company-managed projects, an admin can check **Settings → System → Admin Helper → Permission Helper → Browse Projects**, using the affected user and issue key. Resolve Jira access first, then sync again. See [Atlassian's Permission Helper instructions](https://support.atlassian.com/jira-cloud-administration/docs/check-a-users-access-from-a-work-item/). ## Self-hosted operator setup -The deployment operator configures one shared Jira OAuth integration. Teammates continue to start **Connect** 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. +Configure one shared Jira OAuth app for the deployment: -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 **Integrations**. Confirm that Atlassian lists the intended site, then return to Sim. After changing the deployment's OAuth client or requested scopes, an organization admin selects **Settings → Sources → More → Refresh connection settings**, then affected teammates reconnect. +1. In the [Atlassian developer console](https://developer.atlassian.com/console/myapps/), select or create an **OAuth 2.0 integration**. +2. Under **Authorization → OAuth 2.0 (3LO)**, save `https:///api/auth/oauth2/callback/jira` as a callback, preserving callbacks used by other deployments. +3. Under **Permissions**, add **Jira API** and configure the full `jira` scope list from [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts), including its Jira Service Management and Assets scopes. Add **User Identity API → read:me**. Sim requests `offline_access` for refresh tokens; Search's three scopes above are only a subset of this shared app's permissions. +4. Enable sharing under **Distribution**. Set `JIRA_CLIENT_ID` and `JIRA_CLIENT_SECRET` from the app's **Settings**, verify `NEXT_PUBLIC_APP_URL`, and restart Sim. +5. Connect from **Integrations** and select the configured site. After changing the OAuth client or requested scopes, use **Settings → Sources → More → Refresh connection settings**, then have affected teammates reconnect. -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 **Integrations**. 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). +The callback must match exactly, including scheme, hostname, port, and path. For `http://localhost:3000`, register `http://localhost:3000/api/auth/oauth2/callback/jira`. Use a separate development app when production callbacks must stay unchanged. If only the app owner can connect, check **Distribution**. See the [Atlassian OAuth guide](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) and [Sim deployment reference](/platform/self-hosting/integrations-oauth). diff --git a/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx b/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx index aea98ec0b7b..c5e75a3a749 100644 --- a/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx @@ -1,7 +1,7 @@ 'use client' import { useMemo } from 'react' -import { Chip } from '@sim/emcn' +import { Chip, toast } from '@sim/emcn' import type { ResourceScope } from '@/lib/core/resource-scope' import { connectorDisplayName, @@ -64,6 +64,7 @@ export function ConnectAccountOptions({ membershipQueryKeys, connectedConnectorIds, directOAuth: true, + onConnectionError: toast.error, }) const visibleSources = sources.data?.filter( @@ -206,15 +207,11 @@ export function ConnectAccountOptions({ {search ? 'No matching integrations.' : 'No integrations are available to connect.'} ) : null} - {enrollment.error && ( -

{enrollment.error}

- )} {enrollment.setupConnector && ( enrollment.connectSource(scope, enrollment.setupConnector!.type, config) diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx index edf5d144b50..5ffeeca2f52 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx @@ -1,8 +1,10 @@ /** @vitest-environment jsdom */ import { act, type ReactNode } from 'react' +import { toast } from '@sim/emcn' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connectors' +import { SEARCH_CONNECTORS, type SearchConnector } from '@/lib/sim-search/connectors' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' const mocks = vi.hoisted(() => ({ @@ -16,6 +18,8 @@ const mocks = vi.hoisted(() => ({ availability: vi.fn(), refetchAvailability: vi.fn(), enrollment: vi.fn(), + enrollmentError: null as string | null, + setupConnector: null as SearchConnector | null, })) vi.mock('@/app/o/[organizationId]/integrations/slack-search-actions', () => ({ @@ -65,7 +69,9 @@ vi.mock('@/hooks/use-member-enrollment', () => ({ connectSearchSource: mocks.connect, isAwaiting: () => false, isPending: false, - error: null, + error: mocks.enrollmentError, + setupConnector: mocks.setupConnector, + closeSetup: vi.fn(), } }, })) @@ -112,6 +118,9 @@ describe('organization integrations role and source paths', () => { beforeEach(() => { vi.clearAllMocks() + vi.spyOn(toast, 'error').mockReturnValue('toast-id') + mocks.enrollmentError = null + mocks.setupConnector = null vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) mocks.context.mockReturnValue({ organization: { id: scope.organizationId }, @@ -151,6 +160,7 @@ describe('organization integrations role and source paths', () => { afterEach(async () => { await act(async () => root.unmount()) vi.useRealTimers() + vi.restoreAllMocks() container.remove() vi.unstubAllGlobals() }) @@ -165,6 +175,32 @@ describe('organization integrations role and source paths', () => { ) } + it.each([OrganizationIntegrations, ConnectAccountOptions])( + 'shows connection errors in a toast without adding inline error text in %s', + async (Component) => { + const message = 'Choose the account matching your Sim email address.' + mocks.enrollmentError = message + await act(async () => root.render()) + const options = mocks.enrollment.mock.calls[0][0] as { + onConnectionError: (message: string) => void + } + act(() => options.onConnectionError(message)) + expect(toast.error).toHaveBeenCalledExactlyOnceWith(message) + expect(container.textContent).not.toContain(message) + await act(async () => root.render()) + expect(toast.error).toHaveBeenCalledOnce() + } + ) + + it('keeps source setup fields after a failure without duplicating the toast inside the modal', async () => { + const message = 'Connection unavailable' + mocks.enrollmentError = message + mocks.setupConnector = SEARCH_CONNECTORS.find((connector) => connector.type === 'jira') ?? null + await render() + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + expect(document.body.textContent).not.toContain(message) + }) + it('uses the actual organization and only asks members to connect identity-dependent sources', async () => { await render() expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '' }) diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx index c747fe64af0..da715657d45 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx @@ -1,6 +1,7 @@ 'use client' import { useMemo } from 'react' +import { toast } from '@sim/emcn' import type { ResourceScope } from '@/lib/core/resource-scope' import type { SearchConnectionTarget } from '@/lib/knowledge/search/connection-target' import { connectorDisplayName } from '@/lib/sim-search/connectors' @@ -60,6 +61,7 @@ export function OrganizationIntegrations({ membershipQueryKeys, connectedConnectorIds, directOAuth: true, + onConnectionError: toast.error, }) return ( @@ -121,9 +123,6 @@ export function OrganizationIntegrations({ ) : null} - {enrollment.error && ( -

{enrollment.error}

- )} { '/o/org-one/settings/integrations/providers/google_drive' ) }) + + it.each(['members', 'admin'] as const)( + 'links member sources to personal Search connections: %s', + async (accessMode) => { + mocks.detail.mockReturnValue({ data: { ...connector, accessMode } }) + await render() + const link = container.querySelector('a[aria-label="Manage your Search accounts"]') + if (accessMode === 'members') { + expect(link).toHaveAttribute('href', '/o/org-one/integrations') + expect(container.textContent).toContain( + 'Each person connects from Integrations to sync content they can access.' + ) + } else { + expect(link).toBeNull() + } + } + ) it('restores document search and status from the shared URL', async () => { await render('?search=notes&document-filter=excluded') expect(mocks.documents).toHaveBeenLastCalledWith( diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx index 648ca14f1f6..1439c907041 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx @@ -241,6 +241,15 @@ function SourceDetailContent({ description='Its content is unavailable in Search, Assistant, and MCP.' /> )} + {connector.accessMode === 'members' && ( + + )} ) if (view === 'settings') diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.test.tsx index 2f33ace6fd8..f2aa5297030 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.test.tsx @@ -30,12 +30,21 @@ vi.mock('@sim/emcn', () => ({ ), ChipModalFooter: ({ primaryAction, + secondaryActions = [], }: { primaryAction: { label: string; onClick: () => void; disabled: boolean } + secondaryActions?: { label: string; onClick: () => void }[] }) => ( - + <> + + {secondaryActions.map((action) => ( + + ))} + ), SecretInput: () => null, })) @@ -70,7 +79,7 @@ const serviceAccountJson = JSON.stringify({ project_id: 'test-project', }) -describe('Google service-account creation', () => { +describe('service-account setup', () => { let container: HTMLDivElement let root: Root beforeEach(() => { @@ -82,10 +91,41 @@ describe('Google service-account creation', () => { mocks.create.mockResolvedValue({ credential: { id: 'credential-1' } }) }) afterEach(() => { + vi.restoreAllMocks() act(() => root.unmount()) container.remove() }) + it.each([ + [undefined, 'https://docs.sim.ai/integrations/atlassian-service-account'], + [ + 'https://docs.sim.ai/search/confluence#using-a-service-account', + 'https://docs.sim.ai/search/confluence#using-a-service-account', + ], + ])('opens the applicable Atlassian setup guide (%s)', (setupGuideUrl, expectedUrl) => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + act(() => + root.render( + null} + /> + ) + ) + act(() => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Setup guide')! + .click() + }) + expect(open).toHaveBeenCalledExactlyOnceWith(expectedUrl, '_blank', 'noopener,noreferrer') + }) + it.each([{ organizationId: 'org-1' }, { workspaceId: 'workspace-1' }])( 'submits the Google provider and exact owner for %j', async (owner) => { diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index 69ca9479939..e1b3d63d2b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -115,6 +115,7 @@ interface ConnectServiceAccountModalProps { organizationId?: string serviceAccountProviderId: ServiceAccountProviderId atlassianProduct?: AtlassianProduct + atlassianSetupGuideUrl?: string serviceName: string serviceIcon: ComponentType<{ className?: string }> /** @@ -150,6 +151,7 @@ export function ConnectServiceAccountModal({ organizationId, serviceAccountProviderId, atlassianProduct, + atlassianSetupGuideUrl, serviceName, serviceIcon, credentialId, @@ -211,6 +213,7 @@ export function ConnectServiceAccountModal({ return ( openDocs(ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL), + onClick: () => openDocs(setupGuideUrl), }, ]} primaryAction={{ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx index 6eb9503c972..4327e590b7d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx @@ -830,6 +830,7 @@ describe('Account connection dropdown', () => { expect.objectContaining({ serviceAccountProviderId: 'atlassian-service-account', atlassianProduct: 'confluence', + atlassianSetupGuideUrl: 'https://docs.sim.ai/search/confluence#using-a-service-account', }) ) await act(async () => button('Finish service account setup').click()) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 915509b8e27..2478d941f61 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -681,6 +681,11 @@ export function AddConnectorModal({ ? 'Account' : 'Service account' } + hint={ + isSearchIndex && isMembersMode + ? 'Used to browse available content. Each person connects separately from Integrations to sync their Search content.' + : undefined + } > {credentialsError && rawCredentials.length === 0 ? ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.test.tsx index c36bca66312..0229bf3bb4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.test.tsx @@ -327,6 +327,10 @@ describe('connector settings service-account choices', () => { organizationId: 'org-1', serviceAccountProviderId: provider, atlassianProduct: product, + atlassianSetupGuideUrl: + product === 'confluence' + ? 'https://docs.sim.ai/search/confluence#using-a-service-account' + : undefined, }) ) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx index 65f3abd95eb..06e88330934 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx @@ -317,6 +317,11 @@ export function ConnectorSettingsFields({ {showServiceAccountModal && serviceAccountTarget && canAdmin && ( field.type === 'selector' && isFieldVisible(field) ) && ( - + ({ label: credential.name || credential.provider, diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts index a45dad1173b..7e98d28c1fe 100644 --- a/apps/sim/connectors/confluence/confluence.ts +++ b/apps/sim/connectors/confluence/confluence.ts @@ -25,6 +25,7 @@ import { listAncestorIds, listSpaceReadPrincipals, openConfluenceDirectory, + validateConfluencePermissionAccess, } from '@/connectors/confluence/permissions' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' import { @@ -37,6 +38,7 @@ import { import { getConfluenceCloudId, normalizeConfluenceDomainHost } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceConnector') +const PERMISSION_VALIDATION_TIMEOUT_MS = 10_000 /** * The configured space does not exist for the caller. Confluence answers a @@ -736,12 +738,15 @@ export const confluenceConnector: ConnectorConfig = { } try { - const cloudId = await resolveCloudId( - accessToken, - sourceConfig, - syncContext, - VALIDATE_RETRY_OPTIONS - ) + const retryOptions = + syncContext?.mirrorsSourceAcls === true + ? { + ...VALIDATE_RETRY_OPTIONS, + retryBudgetMs: PERMISSION_VALIDATION_TIMEOUT_MS, + signal: AbortSignal.timeout(PERMISSION_VALIDATION_TIMEOUT_MS), + } + : VALIDATE_RETRY_OPTIONS + const cloudId = await resolveCloudId(accessToken, sourceConfig, syncContext, retryOptions) const params = new URLSearchParams() for (const key of spaceKeys) params.append('keys', key) params.append('limit', String(Math.max(spaceKeys.length, 1))) @@ -755,7 +760,7 @@ export const confluenceConnector: ConnectorConfig = { Authorization: `Bearer ${accessToken}`, }, }, - VALIDATE_RETRY_OPTIONS + retryOptions ) if (!response.ok) { return { valid: false, error: `Failed to validate spaces: ${response.status}` } @@ -770,6 +775,19 @@ export const confluenceConnector: ConnectorConfig = { error: `Space${missing.length > 1 ? 's' : ''} not found: ${missing.join(', ')}`, } } + if (syncContext?.mirrorsSourceAcls === true) { + const spaceId = results[0]?.id + if (typeof spaceId !== 'string' || !spaceId) { + return { valid: false, error: 'Confluence returned a space without an ID. Try again.' } + } + await validateConfluencePermissionAccess({ + cloudId, + accessToken, + spaceId, + contentType: (sourceConfig.contentType as string) || 'page', + retryOptions, + }) + } return { valid: true } } catch (error) { return { valid: false, error: getErrorMessage(error, 'Failed to validate configuration') } diff --git a/apps/sim/connectors/confluence/permissions.ts b/apps/sim/connectors/confluence/permissions.ts index c673750aeea..99405116121 100644 --- a/apps/sim/connectors/confluence/permissions.ts +++ b/apps/sim/connectors/confluence/permissions.ts @@ -1,10 +1,11 @@ import { createLogger } from '@sim/logger' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' import { type ConfluencePrincipal, type ConfluenceRestriction, confluenceSubjectToken, } from '@/lib/knowledge/access/confluence-permissions' -import { fetchWithRetry } from '@/lib/knowledge/documents/utils' +import { fetchWithRetry, type RetryOptions } from '@/lib/knowledge/documents/utils' import { extractCursor } from '@/connectors/confluence/cursor' import type { ConnectorDirectory, @@ -19,35 +20,54 @@ const GROUP_PAGE_SIZE = 200 /** Bounds provider pagination, including malformed continuation responses. */ const MAX_PAGES = 100 +const PREFLIGHT_RESPONSE_MAX_BYTES = 256 * 1024 function apiBase(cloudId: string): string { return `https://api.atlassian.com/ex/confluence/${cloudId}/wiki` } +interface ConfluenceGetOptions { + retryOptions?: RetryOptions + maxResponseBytes?: number +} + /** * A GET with the same transient-error retry every other Confluence call gets. * With `allowNotFound`, a 404 resolves to null instead of throwing. */ -async function getJson(url: string, accessToken: string): Promise async function getJson( url: string, accessToken: string, - options: { allowNotFound: true } + options?: ConfluenceGetOptions & { allowNotFound?: false } +): Promise +async function getJson( + url: string, + accessToken: string, + options: ConfluenceGetOptions & { allowNotFound: true } ): Promise async function getJson( url: string, accessToken: string, - options?: { allowNotFound: true } + options?: ConfluenceGetOptions & { allowNotFound?: boolean } ): Promise { - const response = await fetchWithRetry(url, { - method: 'GET', - headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, - }) + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, + }, + options?.retryOptions + ) if (response.status === 404 && options?.allowNotFound) return null if (!response.ok) { throw new Error(`Confluence request failed: ${response.status} ${response.statusText}`) } - return (await response.json()) as T + return options?.maxResponseBytes + ? readResponseJsonWithLimit(response, { + maxBytes: options.maxResponseBytes, + label: 'Confluence permission check', + }) + : ((await response.json()) as T) } /** @@ -106,6 +126,114 @@ interface SpaceRoleAssignment { principal?: { principalType?: string; principalId?: string } } +/** + * Checks mirrored-permission capabilities on one selected space, one site group, + * and one item of each requested content type. Every collection is a single + * bounded request; continuations are deliberately ignored. Empty collections + * remain valid, but cannot prove access to an endpoint requiring an item ID. + * This does not certify every document's ACL: the crawl verifies each separately. + */ +export async function validateConfluencePermissionAccess(input: { + cloudId: string + accessToken: string + spaceId: string + contentType: string + retryOptions: RetryOptions +}): Promise { + const { cloudId, accessToken, spaceId, contentType, retryOptions } = input + const base = apiBase(cloudId) + const read = async (path: string, capability: string, scopes: string): Promise => { + try { + return await getJson(`${base}${path}`, accessToken, { + retryOptions, + maxResponseBytes: PREFLIGHT_RESPONSE_MAX_BYTES, + }) + } catch { + if (retryOptions.signal?.aborted) { + throw new Error('Confluence permission checks timed out. Try again.') + } + throw new Error( + `Could not verify Confluence ${capability}. Check the service account's access and API token scopes (${scopes}), then try again.` + ) + } + } + const collection = async (path: string, capability: string, scopes: string): Promise => { + const body = await read<{ results?: T[] }>(path, capability, scopes) + if (!Array.isArray(body?.results)) { + throw new Error(`Confluence returned an invalid ${capability} response. Try again.`) + } + return body.results + } + + const groups = await collection<{ id?: string }>( + '/rest/api/group?limit=1', + 'group directory', + 'read:group:confluence' + ) + if (groups.length > 0) { + const groupId = groups[0]?.id + if (!groupId) throw new Error('Confluence returned a group without an ID. Try again.') + await collection( + `/rest/api/group/${encodeURIComponent(groupId)}/membersByGroupId?limit=1`, + 'group membership', + 'read:group:confluence and read:user:confluence' + ) + } + + const encodedSpaceId = encodeURIComponent(spaceId) + const permissions = await collection( + `/api/v2/spaces/${encodedSpaceId}/permissions?limit=${PAGE_SIZE}`, + 'space permissions', + 'read:space:confluence' + ) + if ( + permissions.some( + (entry) => + entry?.operation?.key === 'read' && + entry.operation.targetType === 'space' && + entry.principal?.type?.toLowerCase() === 'role' + ) + ) { + await collection( + `/api/v2/spaces/${encodedSpaceId}/role-assignments?limit=1`, + 'space role assignments', + 'read:space.permission:confluence' + ) + } + + const contentTypes = contentType === 'all' ? ['page', 'blogpost'] : [contentType] + for (const type of contentTypes) { + const collectionName = type === 'blogpost' ? 'blogposts' : 'pages' + const content = await collection<{ id?: string }>( + `/api/v2/spaces/${encodedSpaceId}/${collectionName}?limit=1&status=current`, + `${collectionName} for the permission check`, + type === 'blogpost' ? 'read:blogpost:confluence' : 'read:page:confluence' + ) + if (content.length === 0) continue + const contentId = content[0]?.id + if (!contentId) throw new Error('Confluence returned content without an ID. Try again.') + const encodedContentId = encodeURIComponent(contentId) + const restriction = await read( + `/rest/api/content/${encodedContentId}/restriction/byOperation/read?expand=restrictions.user,restrictions.group&limit=1`, + 'content restrictions', + 'read:confluence-content.all' + ) + if ( + !Array.isArray(restriction?.restrictions?.user?.results) || + !Array.isArray(restriction.restrictions.group?.results) + ) { + throw new Error('Confluence returned invalid content restrictions. Try again.') + } + if (type !== 'blogpost') { + await collection( + `/api/v2/pages/${encodedContentId}/ancestors?limit=1`, + 'ancestor metadata', + 'read:content.metadata:confluence' + ) + } + } +} + /** * Space roles include read access. Their assignments may be flattened into * permission entries or returned separately. Licensed-user and product-admin diff --git a/apps/sim/connectors/confluence/preflight.test.ts b/apps/sim/connectors/confluence/preflight.test.ts new file mode 100644 index 00000000000..5bcf03e534a --- /dev/null +++ b/apps/sim/connectors/confluence/preflight.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { confluenceConnector } from '@/connectors/confluence/confluence' + +const CONFIG = { domain: 'example.atlassian.net', spaceKey: ['ENG', 'PRODUCT'] } +const CONTEXT = { + cloudId: 'cloud-1', + credentialDomain: 'example.atlassian.net', + mirrorsSourceAcls: true, +} +const fetchMock = vi.fn() + +function pathOf(input: string | URL | Request): string { + return new URL(input instanceof Request ? input.url : String(input)).pathname +} + +function responseFor(path: string): Response { + if (path.endsWith('/spaces')) { + return Response.json({ + results: [ + { id: '101', key: 'ENG' }, + { id: '102', key: 'PRODUCT' }, + ], + }) + } + if (path.endsWith('/group')) { + return Response.json({ results: [{ id: 'group-1' }] }) + } + if (path.endsWith('/permissions')) { + return Response.json({ + results: [{ principal: { type: 'role' }, operation: { key: 'read', targetType: 'space' } }], + }) + } + if (path.endsWith('/pages')) return Response.json({ results: [{ id: '201' }] }) + if (path.endsWith('/blogposts')) return Response.json({ results: [{ id: '301' }] }) + if (path.endsWith('/restriction/byOperation/read')) { + return Response.json({ restrictions: { user: { results: [] }, group: { results: [] } } }) + } + return Response.json({ results: [] }) +} + +function requestPaths(): string[] { + return fetchMock.mock.calls.map(([input]) => pathOf(input)) +} + +beforeEach(() => { + fetchMock.mockReset() + fetchMock.mockImplementation(async (input) => responseFor(pathOf(input))) + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('Confluence mirrored permission preflight', () => { + it('accepts a central token after checking directory, membership, roles, and page permissions', async () => { + await expect(confluenceConnector.validateConfig('token', CONFIG, CONTEXT)).resolves.toEqual({ + valid: true, + }) + expect(requestPaths()).toEqual( + expect.arrayContaining([ + '/ex/confluence/cloud-1/wiki/rest/api/group', + '/ex/confluence/cloud-1/wiki/rest/api/group/group-1/membersByGroupId', + '/ex/confluence/cloud-1/wiki/api/v2/spaces/101/permissions', + '/ex/confluence/cloud-1/wiki/api/v2/spaces/101/role-assignments', + '/ex/confluence/cloud-1/wiki/rest/api/content/201/restriction/byOperation/read', + '/ex/confluence/cloud-1/wiki/api/v2/pages/201/ancestors', + ]) + ) + expect(fetchMock).toHaveBeenCalledTimes(8) + }) + + it.each([ + [undefined, undefined], + [false, true], + ['true', undefined], + ])( + 'does not require permission reads without the trusted central flag (%s)', + async (mirrorsSourceAcls, perMemberListing) => { + await expect( + confluenceConnector.validateConfig('token', CONFIG, { + ...CONTEXT, + mirrorsSourceAcls, + perMemberListing, + }) + ).resolves.toEqual({ valid: true }) + expect(fetchMock).toHaveBeenCalledTimes(1) + } + ) + + it.each([ + ['/group', 'group directory', 'read:group:confluence'], + ['/membersByGroupId', 'group membership', 'read:user:confluence'], + ['/permissions', 'space permissions', 'read:space:confluence'], + ['/role-assignments', 'space role assignments', 'read:space.permission:confluence'], + ['/restriction/byOperation/read', 'content restrictions', 'read:confluence-content.all'], + ['/ancestors', 'ancestor metadata', 'read:content.metadata:confluence'], + ])('rejects missing access to %s with a useful scope hint', async (suffix, capability, scope) => { + fetchMock.mockImplementation(async (input) => { + const path = pathOf(input) + return path.endsWith(suffix) + ? Response.json({ error: 'provider-secret-do-not-echo' }, { status: 403 }) + : responseFor(path) + }) + + const result = await confluenceConnector.validateConfig('token', CONFIG, CONTEXT) + expect(result.valid).toBe(false) + expect(result.error).toContain(capability) + expect(result.error).toContain(scope) + expect(result.error).not.toContain('provider-secret-do-not-echo') + }) + + it('allows empty spaces and directories without claiming an item-level probe', async () => { + fetchMock.mockImplementation(async (input) => { + const path = pathOf(input) + return path.endsWith('/spaces') ? responseFor(path) : Response.json({ results: [] }) + }) + await expect(confluenceConnector.validateConfig('token', CONFIG, CONTEXT)).resolves.toEqual({ + valid: true, + }) + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(requestPaths().some((path) => path.includes('restriction'))).toBe(false) + expect(requestPaths().some((path) => path.includes('membersByGroupId'))).toBe(false) + }) + + it('checks blog restrictions without requiring page or ancestor access for a blog-only source', async () => { + await expect( + confluenceConnector.validateConfig('token', { ...CONFIG, contentType: 'blogpost' }, CONTEXT) + ).resolves.toEqual({ valid: true }) + expect(requestPaths()).toContain( + '/ex/confluence/cloud-1/wiki/rest/api/content/301/restriction/byOperation/read' + ) + expect( + requestPaths().some((path) => path.endsWith('/pages') || path.endsWith('/ancestors')) + ).toBe(false) + }) + + it('bounds all-content validation to one space and one item of each type, ignoring every continuation', async () => { + fetchMock.mockImplementation(async (input) => { + const body = await responseFor(pathOf(input)).json() + return Response.json({ ...body, _links: { next: '/next?cursor=do-not-follow&start=1' } }) + }) + await expect( + confluenceConnector.validateConfig('token', { ...CONFIG, contentType: 'all' }, CONTEXT) + ).resolves.toEqual({ valid: true }) + expect(fetchMock).toHaveBeenCalledTimes(10) + expect(requestPaths().some((path) => path.includes('/spaces/102/'))).toBe(false) + for (const [input] of fetchMock.mock.calls.slice(1)) { + const url = new URL(String(input)) + expect(url.searchParams.has('cursor')).toBe(false) + expect(Number(url.searchParams.get('limit'))).toBeLessThanOrEqual(250) + } + }) + + it('rejects missing source spaces before making permission probes', async () => { + await expect( + confluenceConnector.validateConfig('token', { ...CONFIG, spaceKey: [] }, CONTEXT) + ).resolves.toEqual({ valid: false, error: 'Domain and at least one space key are required' }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects a malformed directory response instead of treating it as an empty directory', async () => { + fetchMock.mockImplementation(async (input) => + pathOf(input).endsWith('/group') ? Response.json({}) : responseFor(pathOf(input)) + ) + await expect(confluenceConnector.validateConfig('token', CONFIG, CONTEXT)).resolves.toEqual({ + valid: false, + error: 'Confluence returned an invalid group directory response. Try again.', + }) + }) + + it('rejects unexpanded content restrictions', async () => { + fetchMock.mockImplementation(async (input) => + pathOf(input).includes('/restriction/') + ? Response.json({ restrictions: {} }) + : responseFor(pathOf(input)) + ) + await expect(confluenceConnector.validateConfig('token', CONFIG, CONTEXT)).resolves.toEqual({ + valid: false, + error: 'Confluence returned invalid content restrictions. Try again.', + }) + }) + + it('bounds provider response bytes even when a requested one-item response is oversized', async () => { + fetchMock.mockImplementation(async (input) => + pathOf(input).endsWith('/group') + ? Response.json({ results: [{ id: 'group-1' }], padding: 'x'.repeat(256 * 1024) }) + : responseFor(pathOf(input)) + ) + const result = await confluenceConnector.validateConfig('token', CONFIG, CONTEXT) + expect(result.valid).toBe(false) + expect(result.error).toContain('group directory') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('declines provider retry waits longer than the validation budget', async () => { + fetchMock.mockImplementation(async (input) => + pathOf(input).endsWith('/group') + ? Response.json({}, { status: 429, headers: { 'Retry-After': '120' } }) + : responseFor(pathOf(input)) + ) + const result = await confluenceConnector.validateConfig('token', CONFIG, CONTEXT) + expect(result.valid).toBe(false) + expect(result.error).toContain('group directory') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('cancels subsequent probes when the shared validation deadline expires', async () => { + const deadline = new AbortController() + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal) + fetchMock.mockImplementation(async (input) => { + if (pathOf(input).endsWith('/group')) deadline.abort(new Error('Validation deadline')) + return responseFor(pathOf(input)) + }) + const result = await confluenceConnector.validateConfig('token', CONFIG, CONTEXT) + expect(timeout).toHaveBeenCalledWith(10_000) + expect(result.valid).toBe(false) + expect(result.error).toBe('Confluence permission checks timed out. Try again.') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/hooks/use-member-enrollment.test.tsx b/apps/sim/hooks/use-member-enrollment.test.tsx index 9fe93f8ef8f..6efb3ce8138 100644 --- a/apps/sim/hooks/use-member-enrollment.test.tsx +++ b/apps/sim/hooks/use-member-enrollment.test.tsx @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ enrollmentMutate: vi.fn(), sourceConnectionMutate: vi.fn(), invalidateQueries: vi.fn(), + connectionError: vi.fn(), channels: [] as Array<{ name: string onmessage: ((event: MessageEvent) => void) | null @@ -48,24 +49,39 @@ let enrollmentTab: { location: { href: string }; closed: boolean; close: () => v function Harness({ connected, directOAuth, + onConnectionError, }: { connected: ReadonlySet directOAuth?: boolean + onConnectionError?: (message: string) => void }) { latest = useMemberEnrollment({ membershipQueryKeys: [], connectedConnectorIds: connected, directOAuth, + onConnectionError, }) return null } -function mount(connected: ReadonlySet = new Set(), directOAuth = false) { +function mount( + connected: ReadonlySet = new Set(), + directOAuth = false, + onConnectionError?: (message: string) => void +) { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) - act(() => root?.render()) + act(() => + root?.render( + + ) + ) } function enrollment(): Enrollment { @@ -107,6 +123,67 @@ afterEach(() => { }) describe('useMemberEnrollment', () => { + it('reports an OAuth mismatch once per attempt and allows the same error on a later retry', () => { + mount(new Set(), true, mocks.connectionError) + for (let index = 0; index < 2; index += 1) { + act(() => enrollment().connect('kb-1', 'connector-1')) + act(() => + mocks.enrollmentMutate.mock.calls[index][1].onSuccess({ + url: 'https://provider.test/authorize', + }) + ) + act(() => + mocks.channels[index].onmessage?.(new MessageEvent('message', { data: 'account_mismatch' })) + ) + act(() => + mocks.channels[index].onmessage?.(new MessageEvent('message', { data: 'account_mismatch' })) + ) + expect(mocks.connectionError).toHaveBeenCalledTimes(index + 1) + expect(enrollment().isAwaiting('connector-1')).toBe(false) + } + expect(mocks.connectionError).toHaveBeenLastCalledWith( + 'Choose the account matching your Sim email address.' + ) + act(() => vi.advanceTimersByTime(10 * 60_000)) + expect(mocks.connectionError).toHaveBeenCalledTimes(2) + }) + + it('does not report a successful OAuth completion as an error', () => { + mount(new Set(), true, mocks.connectionError) + act(() => enrollment().connect('kb-1', 'connector-1')) + act(() => + mocks.enrollmentMutate.mock.calls[0][1].onSuccess({ + url: 'https://provider.test/authorize', + }) + ) + act(() => mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'connected' }))) + expect(mocks.connectionError).not.toHaveBeenCalled() + }) + + it('reports a blocked popup once without starting a connection', () => { + mount(new Set(), true, mocks.connectionError) + vi.mocked(window.open).mockReturnValueOnce(null) + act(() => enrollment().connect('kb-1', 'connector-1')) + expect(mocks.connectionError).toHaveBeenCalledExactlyOnceWith( + 'Allow pop-ups for this site to connect your account.' + ) + expect(mocks.enrollmentMutate).not.toHaveBeenCalled() + }) + + it.each(['existing', 'new'] as const)('reports %s source startup errors once', (source) => { + mount(new Set(), true, mocks.connectionError) + act(() => { + if (source === 'existing') enrollment().connect('kb-1', 'connector-1') + else enrollment().connectSource({ kind: 'organization', organizationId: 'org-1' }, 'jira') + }) + const mutation = source === 'existing' ? mocks.enrollmentMutate : mocks.sourceConnectionMutate + act(() => mutation.mock.calls[0][1].onError(new Error('Connection unavailable'))) + expect(mocks.connectionError).toHaveBeenCalledExactlyOnceWith('Connection unavailable') + act(() => vi.advanceTimersByTime(10 * 60_000)) + expect(mocks.connectionError).toHaveBeenCalledOnce() + expect(mocks.channels[0].close).toHaveBeenCalledOnce() + }) + it('opens provider OAuth and waits for its own completion even if the account was already connected', () => { mount(new Set(['connector-1']), true) act(() => enrollment().connect('kb-1', 'connector-1')) diff --git a/apps/sim/hooks/use-member-enrollment.ts b/apps/sim/hooks/use-member-enrollment.ts index 4d1b2b14ac1..75826480eb1 100644 --- a/apps/sim/hooks/use-member-enrollment.ts +++ b/apps/sim/hooks/use-member-enrollment.ts @@ -109,6 +109,7 @@ interface UseMemberEnrollmentProps { connectedConnectorIds: ReadonlySet /** Main Integrations skips the invitation page; invitation-based surfaces keep their flow. */ directOAuth?: boolean + onConnectionError?: (message: string) => void } /** @@ -126,6 +127,7 @@ export function useMemberEnrollment({ membershipQueryKeys, connectedConnectorIds, directOAuth = false, + onConnectionError, }: UseMemberEnrollmentProps) { const connectedRef = useRef(connectedConnectorIds) const oauthPopups = useRef( @@ -158,6 +160,7 @@ export function useMemberEnrollment({ new Map([...current].filter(([, entry]) => entry.oauthCompletionId !== completionId)) ) setOAuthError(error) + if (error) onConnectionError?.(error) refreshMemberships() } @@ -215,6 +218,7 @@ export function useMemberEnrollment({ const tab = window.open('about:blank', '_blank') if (!tab) { setPopupBlocked(true) + onConnectionError?.(POPUP_BLOCKED_MESSAGE) return } tab.opener = null @@ -267,6 +271,7 @@ export function useMemberEnrollment({ onSuccess: ({ url }) => onSuccess(url, connectorId), onError: (err) => { onError() + onConnectionError?.(err.message) logger.error('Failed to start member enrollment', { error: err.message }) }, } @@ -297,6 +302,7 @@ export function useMemberEnrollment({ }, onError: (err) => { onError() + onConnectionError?.(err.message) logger.error('Failed to connect a Sim Search source', { error: err.message }) }, } From 4d5e0570d70b0cb94766b0516caf931294a0793a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 10 Sep 2026 00:58:42 -0700 Subject: [PATCH 2/2] chore(docs): clarify Confluence setup permission sampling --- apps/docs/content/docs/search/confluence.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/docs/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx index dc78d10cb2c..308ca638a00 100644 --- a/apps/docs/content/docs/search/confluence.mdx +++ b/apps/docs/content/docs/search/confluence.mdx @@ -52,7 +52,7 @@ Open **More options** for content type, labels, and metadata tags. The default i ### Sync and connect your identity -Select **Connect & Sync**. Setup checks available permission data; each document's access is verified during sync. Then open **Integrations** in the main sidebar and select **Connect** on the Confluence source. In the new tab, authorize the configured site using the Atlassian email matching your verified Sim email. +Select **Connect & Sync**. Setup samples permission access in one selected space. Each document's access is verified during sync. Then open **Integrations** in the main sidebar and select **Connect** on the Confluence source. In the new tab, authorize the configured site using the Atlassian email matching your verified Sim email. Each teammate completes this identity connection. An existing authorized account may already be connected. Return to Integrations to check indexing status and your searchable document count.