diff --git a/apps/docs/content/docs/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx index f818dcba9de..8943a97d197 100644 --- a/apps/docs/content/docs/search/confluence.mdx +++ b/apps/docs/content/docs/search/confluence.mdx @@ -31,21 +31,16 @@ Teammates authorize Sim's shared Confluence app, which also requests workflow pe ### Choose Confluence -Open your organization's **Settings → Sources**, turn on **Confluence**, then select **Set up** (or **Manage**) → **Add source**. This opens service-account setup. +Open **Settings → Sources → Add source** and select **Confluence**. This opens **Connect Confluence site** with service-account authentication. To connect another site later, open **Confluence** from the Sources list and select **Add Confluence site**. ### Choose the account and spaces -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. +Under **Service account**, select a service account or [add one](#using-a-service-account). Enter the same **Confluence site** as the credential, then choose **Spaces**. **All** in the dropdown selects every space the account can currently browse; newly created spaces are not added automatically. Clear the picker search before selecting all. -Confluence source setup with a service account, site domain, and spaces +To enter comma-separated keys such as `ENG, PRODUCT`, use the switch beside **Spaces**. Switching between the picker and manual entry keeps your selection. Open **More options** for content type, labels, and metadata tags. The default is **Pages only**; choose **All content** to include blog posts. @@ -65,9 +60,22 @@ For workspace Search, start from **Search → Add source**. Available methods de ## Connect member accounts -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**. +After an admin allows Confluence, open **Integrations → Connect**. For an existing source, authorize your account; you do not choose its spaces again. + +If no source exists, or you choose **Connect a different site or content scope**: + +1. Under **Your account**, select a saved account or choose **Connect account**. Authorize using the Atlassian email matching your verified Sim email. +2. Enter the hostname under **Atlassian site**, then choose **Spaces**. Use **All** in the dropdown for the complete current list, or **Enter keys manually** for comma-separated keys. You can select up to 1,000 spaces in this form. +3. Select **Connect & Sync**. Sim saves the selected scope and starts indexing with your account. + +Confluence personal source setup with a saved account, Atlassian site, and selected spaces -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. +Admins manage these sources under **Settings → Sources → Confluence**. When configuring a member-account connection, an **Account for browsing** populates the space picker; a saved personal Search account can be reused here. Choosing a browsing account does not enroll it for Search. Manual space keys work without a browsing account. ## Using a service account @@ -95,7 +103,7 @@ read:group:confluence Use all 12 scopes for account validation, pickers, content, permissions, and directory reads. Central indexing does not need write scopes. 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. +5. In Sim's source form, use **Service 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. -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. To replace an expiring token or change scopes, add a new credential in the source's **Settings**, select **Change indexing account**, and verify a sync before revoking the old token. +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. To replace an expiring token or change scopes, add a new credential in the source's **Settings**, select **Change service account**, and verify a sync before revoking the old token. ## Configuration and indexed content | Setting | What it controls | | --- | --- | -| **Confluence Domain** | Cloud hostname only, such as `your-team.atlassian.net`; omit page URLs and `/wiki`. | +| **Confluence site** | 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** (default), **Blog posts only**, or **All content** for both. | | **Filter by Label** | Optional comma-separated labels; content can match any listed label. | @@ -122,7 +130,7 @@ Search manages the schedule and hides item limits. It indexes published/current Central sources combine space permissions, page and ancestor restrictions, and group membership. Before returning central content, Sim uses your personal connection to check that you still have access to the configured Confluence site. If Atlassian cannot confirm that access, the content is hidden. 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. -Open **Settings → Sources → Confluence → Manage**, then a source's **Documents**, **Settings**, or **Sync history**. Invite teammates through **Settings → Members → Invite** or SSO, then have them connect through **Integrations**. **Accounts → Request connections** only requests a provider connection; it does not invite people to the organization. +Open **Settings → Sources → Confluence**, then a source's **Documents**, **Settings**, or **Sync history**. Invite teammates through **Settings → Members → Invite** or SSO, then have them connect through **Integrations**. **People → Request connections** only requests a provider connection; it does not invite people to the organization. ## Troubleshooting @@ -147,6 +155,6 @@ Configure one shared Confluence OAuth app for teammates' connections: 2. Under **Authorization → OAuth 2.0 (3LO)**, add `https:///api/auth/oauth2/callback/confluence` as a callback. 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. 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. +5. Connect from **Integrations** and select the configured site. After changing the OAuth client or requested scopes, use **Settings → Sources → More → Update sign-in settings**, then have affected teammates reconnect. The callback must match Sim's scheme, hostname, port, and path exactly. 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/connect-your-account.mdx b/apps/docs/content/docs/search/connect-your-account.mdx index 7b031e1cf82..bfe41deae8f 100644 --- a/apps/docs/content/docs/search/connect-your-account.mdx +++ b/apps/docs/content/docs/search/connect-your-account.mdx @@ -23,7 +23,7 @@ For a central Google source, use your primary Workspace email and open **Search* ## Open Integrations -Open **Integrations** in the main sidebar, find the provider or source, and select **Connect**. Your first connection may ask for a GitHub repository, Confluence domain and space keys, or Jira domain and project keys. Enter the required fields and select **Connect**. If the provider is missing, ask an organization admin to turn it on under **Settings → Sources**. +Open **Integrations** in the main sidebar, find the provider or source, and select **Connect**. First-time setup can also ask which content to index. For [Confluence](/search/confluence) and [Jira](/search/jira), connect your Atlassian account first, then choose the site and spaces or projects and select **Connect & Sync**. [GitHub](/search/github) asks for a repository. If the provider is missing, ask an organization admin to add it under **Settings → Sources → Add source**. To connect another supported repository, site, or project scope, find the provider row labeled **Connect a different site or content scope** and select **Connect**. Connecting an existing source does not ask you to configure it again. @@ -36,7 +36,7 @@ To connect another supported repository, site, or project scope, find the provid Complete the provider's authorization in the new tab. Choose the account associated with your verified Sim email. The provider may require your organization's SSO or app approval. -The authorization tab closes when the connection completes and Integrations updates. If the tab stays open, return to Integrations. Your account is saved when authorization completes; there is no separate submit step. If the popup was blocked or closed, allow popups and select **Connect** again. While authorization is pending, use **Open again**. +The authorization tab closes when the connection completes and Integrations updates. If the tab stays open, return to Integrations. For an existing source, your account is saved when authorization completes. For first-time Atlassian setup, return to the form, choose your content, and select **Connect & Sync**. If the popup was blocked or closed, allow popups and select **Connect** again. While authorization is pending, use **Open again**. @@ -74,7 +74,7 @@ An account connection request does not invite you into the Sim organization. You On the main **Integrations** page, use **Reconnect** beside an expired connection to renew it. To withdraw an account, open its row's actions menu and select **Disconnect**, then confirm. If several accounts are connected, choose the account to disconnect. Disconnecting stops that account from being used for organization indexing and workflows, and removes Search access that depends on it. -Admins manage setup from **Settings → Sources**. Select **Manage** beside the integration. Providers with personal connections have **Accounts** and a source list under **Advanced** (Google) or **Sources**. Without personal connections, the source list opens directly. This does not grant the admin access to every document. +Admins manage setup from **Settings → Sources**. Open an integration, then its connection to see **Documents**, **Settings**, and **Sync history**. **People** shows account contributors across integrations and supports filtering by integration. This does not grant the admin access to every document. ## If you get stuck @@ -87,7 +87,7 @@ Admins manage setup from **Settings → Sources**. Select **Manage** beside the | **Verify email** | Verify your Sim email to return to the connection page. Reopen the original link if you are not redirected. | | Expired or cancelled authorization | Return to the original connection page and start again. If the invitation itself expired, ask the admin for a new request. | | Access revoked | Ask the organization admin to restore your account contribution access before reconnecting. | -| Needs admin attention | Ask your admin to open **Settings → Sources**, select **Manage** beside the integration, and open its source or sync configuration to inspect the error. | +| Needs admin attention | Ask your admin to open **Settings → Sources**, select the integration, and open its connection to inspect the error. | Your Sim role does not override document access at the source. Connecting a different account or receiving a Search link does not share someone else's mailbox, private calendar, or restricted documents with you. diff --git a/apps/docs/content/docs/search/github.mdx b/apps/docs/content/docs/search/github.mdx index 9a69063e165..8c3da90651a 100644 --- a/apps/docs/content/docs/search/github.mdx +++ b/apps/docs/content/docs/search/github.mdx @@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image' GitHub Search indexes text files from repositories on `github.com`. An organization admin can install the GitHub App once and use it to index selected repositories. Each person connects their own GitHub account once to search the repositories they can access. Installing the App does not connect teammates or give them the installer's permissions. -Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. Installation indexing is available for organization Search. For workspace Search, use **Search → Add source** with member accounts or a dedicated user account; **Create & Invite** is the workspace equivalent of **Add source**. +Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. Installation indexing is available for organization Search. For workspace Search, use **Search → Add source** with member accounts or a dedicated user account. ## Before you start @@ -26,7 +26,7 @@ To connect an installation for central indexing, you must be a Sim organization ### Open GitHub setup -Open **Settings → Sources** and turn on **GitHub**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. +Open **Settings → Sources → Add source** and select **GitHub**. This opens **Add GitHub repository**. Add one repository per connection; to add another, open **GitHub** from the Sources list and select **Add repository**. @@ -35,8 +35,6 @@ Open **Settings → Sources** and turn on **GitHub**. Select **Set up** (or **Ma In **Sync documents with**, choose **Connect GitHub App** to index through an installation: -GitHub source setup with indexing choices for connected members, a GitHub account, or a GitHub App - 1. Select **Connect your GitHub account** if prompted. Finish authorization in the new tab, then return and select **Refresh**. Connect GitHub App dialog requiring a personal GitHub connection to verify manageable installations @@ -55,20 +53,18 @@ Alternatively, leave **Connected members** selected to use members' accounts for ### Choose what to index -Enter **Repository** as `owner/repo`. For installation indexing, it must belong to the installation's account and be included in the repositories granted to the App. Add one source per repository; installing on all repositories does not automatically create sources for them. +Enter **Repository** as `owner/repo`. For installation indexing, it must belong to the installation's account and be included in the repositories granted to the App. Add one connection per repository; installing on all repositories does not automatically add them to Search. Open **More options** if you need a different branch, path or extension filters, or metadata tags. -GitHub repository configuration with branch, path filter, file extensions, and metadata tags - | Field | What to enter | |---|---| -| Repository | `owner/repo`. Add another source for another repository. | +| Repository | `owner/repo`. Use **Add repository** for another repository. | | Branch | Optional. Leave blank to follow the repository's default branch. | | Path Filter | Optional prefix such as `docs/`. | | File Extensions | Optional comma-separated list, such as `.md, .txt, .mdx`. | -**Metadata tags** controls the metadata stored with results. Its defaults are suitable for most sources. Select **Add source** to save the source. +**Metadata tags** controls the metadata stored with results. Its defaults are suitable for most sources. Select **Add repository** to save the connection. @@ -79,9 +75,9 @@ Open **Integrations** in the main sidebar, select **Connect** on the GitHub sour An App installation or dedicated indexing account can start syncing after the source is saved. With **Connected members**, indexing begins after someone connects. Each teammate still connects before searching. An existing GitHub connection in the same Sim organization is reused across its GitHub sources. -Open **Settings → Sources**, select **Manage** beside **GitHub**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. +Open **Settings → Sources**, open **GitHub**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. -Use **GitHub → Accounts → Request connections** to send provider-specific connection requests. These requests do not grant organization membership. For another repository, add another source; members can also select **Connect** beside the GitHub row labeled **Connect a different site or content scope** in Integrations. +Use **Settings → Sources → People**, filter by **GitHub**, then select **Request connections** to send provider-specific connection requests. These requests do not grant organization membership. For another repository, add another source; members can also select **Connect** beside the GitHub row labeled **Connect a different site or content scope** in Integrations. @@ -106,7 +102,7 @@ This is an installation plus personal authorization flow. GitHub Search does not | Identity verification fails | Verify the email used by your Sim account in GitHub's email settings, then reconnect. A public profile email alone is insufficient. | | Authorization fails after installation | Return to Sim and start **Connect** there. Do not enable authorization during installation. | | Account authorization did not complete | Start the connection again from Sim. If it repeats, contact your organization admin or Sim support. For self-hosted Sim, check the [App callback and credentials](/platform/self-hosting/integrations-oauth#github-search). | -| GitHub asks for a provider configuration update | An organization admin selects **Settings → Sources → More → Refresh connection settings**, then affected users reconnect GitHub. | +| GitHub asks for a provider configuration update | An organization admin selects **Settings → Sources → More → Update sign-in settings**, then affected users reconnect GitHub. | | Indexed files no longer appear | Confirm your own repository access, App repository selection, and connection status. Installation-indexed content is also withheld when GitHub cannot verify current access; retry once GitHub is available. | | Sync is incomplete | Review the source status. Very large Git trees, file size limits, and unreadable files can limit indexing. | | Empty repository returns an error | Add an initial commit, then sync again. GitHub does not return a file tree for an uninitialized repository. | diff --git a/apps/docs/content/docs/search/gitlab.mdx b/apps/docs/content/docs/search/gitlab.mdx index 39fb464de65..45f91816db9 100644 --- a/apps/docs/content/docs/search/gitlab.mdx +++ b/apps/docs/content/docs/search/gitlab.mdx @@ -9,8 +9,6 @@ import { Image } from '@/components/ui/image' GitLab Search uses an administrator connection to sync a project's content and permissions. Teammates do not connect individual GitLab accounts. They sign in to the Sim organization with a verified email matching their confirmed primary GitLab email. -GitLab source setup in Sim Search - Admin setup uses your organization's **Settings → Sources** page. Teammates do not need a personal GitLab connection. For workspace Search, use **Search → Add source** instead. ## Before you start @@ -49,12 +47,12 @@ The token must read the project, users, inherited project membership, instance s ### Configure the source in Sim -Open **Settings → Sources** and turn on **GitLab**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. Paste the **Personal Access Token**, enter your **Host** and **Project**, and choose the content to index. +Open **Settings → Sources → Add source** and select **GitLab**. This opens **Add GitLab project**. Add one project per connection; to add another, open **GitLab** from the Sources list and select **Add project**. Paste the **Personal Access Token**, enter your **Host** and **Project**, and choose the content to index. | Field | What to enter | |---|---| | Host | Your self-managed domain, such as `gitlab.example.com`. | -| Project | `group/project` or the numeric project ID. Add another source for another project. | +| Project | `group/project` or the numeric project ID. Use **Add project** for another project. | | Content | Defaults to **Wiki & Issues**. Choose **Code, Wiki, Issues & Merge Requests** to include all supported types. | | Branch | Optional branch or tag for repository files; blank uses the project's default branch. | | Path Filter / File Extensions | Optional limits for repository files. | @@ -63,8 +61,6 @@ Open **Settings → Sources** and turn on **GitLab**. Select **Set up** (or **Ma **More options** contains the repository and issue filters, **Max Items**, and **Metadata tags**. Unlike member-account sources, GitLab retains this optional item limit. -GitLab source setup with More options expanded for repository and issue filters, item limit, and metadata tags - Select **Connect & Sync**. Sim validates the token and source policy, then starts indexing. @@ -74,7 +70,7 @@ Select **Connect & Sync**. Sim validates the token and source policy, then start Invite teammates to the Sim organization using their verified work email. Sim matches that email against their confirmed primary GitLab email and applies project, feature, and confidential-issue permissions. Confirmed secondary addresses are not matched. No GitLab **Connect** step is required. -Admins open **Settings → Sources**, select **Manage** beside **GitLab**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. GitLab has no personal **Accounts** tab. Permission and membership changes are picked up during background refreshes. +Admins open **Settings → Sources**, open **GitLab**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. Permission and membership changes are picked up during background refreshes. diff --git a/apps/docs/content/docs/search/gmail.mdx b/apps/docs/content/docs/search/gmail.mdx index b4ce9e2e53f..990e806d51f 100644 --- a/apps/docs/content/docs/search/gmail.mdx +++ b/apps/docs/content/docs/search/gmail.mdx @@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image' Search email threads from your Gmail account. Members can connect their own accounts, or an administrator can index Google Workspace mailboxes with a service account. In either case, each mailbox stays private to its owner. -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**. +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. ## Choose your setup @@ -29,34 +29,34 @@ Central indexing does not make email searchable by the administrator, other reci ### Allow Gmail -Open **Settings → Sources** and turn on **Gmail**. This allows personal connections; it does not connect anyone's account. +An admin selects **Settings → Sources → Add source → Gmail**, chooses **Sync using → Member accounts**, sets any filters, and selects **Set up member accounts**. ### Connect your account -Join the Sim organization, then open **Integrations** and select **Connect** beside Gmail. Authorize the Google account matching your verified Sim email. The first connection creates the default sync configuration: the last 6 months across all labels, excluding Promotions, Social, Spam, and Trash. Return to Integrations to see indexing status and your searchable document count. +Join the Sim organization, then open **Integrations** and select **Connect** beside Gmail. Authorize the Google account matching your verified Sim email. The default filters cover the last 6 months across all labels, excluding Promotions, Social, Spam, and Trash. Return to Integrations to see indexing status and your searchable document count. ### Adjust filters if needed -An admin selects **Manage** beside Gmail in **Settings → Sources**, opens **Advanced**, then selects the configuration's **Settings** tab. Change **Labels**, **Date Range**, or other filters and save. +An admin opens **Settings → Sources → Gmail**, selects the connection, then selects the connection's **Settings** tab. Change **Labels**, **Date Range**, or other filters and save. -One member-account configuration is usually enough. Its filters apply to all active Gmail connections, including accounts connected later. It does not assign different filters to selected people or let teammates search each other's mail. **Add sync configuration** opens the [central service-account setup](#set-up-a-central-service-account); it does not edit this member configuration. +One member-account connection is usually enough. Its filters apply to all active Gmail connections, including accounts connected later. It does not assign different filters to selected people or let teammates search each other's mail. **Connect service account** opens the [central service-account setup](#set-up-a-central-service-account); it does not edit this member connection. -Configurations are additive: a narrower one does not restrict an existing broader one, and overlapping configurations can index the same thread more than once. For one organization-wide policy, edit the existing configuration. +Connections are additive: a narrower one does not restrict an existing broader one, and overlapping connections can index the same thread more than once. For one organization-wide policy, edit the existing connection. -Admins can request member connections from **Manage → Accounts → Request connections**. These requests do not invite recipients to the Sim organization. See [Connect your account](/search/connect-your-account) for the shared connection and recovery steps. +Admins can request member connections from **Settings → Sources → People → Request connections** (filter by the integration first). These requests do not invite recipients to the Sim organization. See [Connect your account](/search/connect-your-account) for the shared connection and recovery steps. ## Set up a central service account -Open **Settings → Sources**, enable **Gmail**, and select **Manage → Advanced → Add sync configuration**. If personal connections are disabled for your organization, select **Add source** from the provider page instead. +Open **Settings → Sources → Add source** and select **Gmail**. This opens **Connect Gmail service account**. To add another connection later, open **Gmail** from the Sources list and select **Connect service account**. This requires Google Workspace and a Workspace super administrator to authorize delegation. Consumer Gmail accounts cannot use this path. Each selected user must have Gmail enabled. @@ -91,7 +91,7 @@ If your organization requires multi-party approval, another super administrator ### Configure Gmail in Sim -Under **Indexing account**, select **Add service account** and paste the JSON key into **Add Google Service Account**, or select an existing service account. Set **Directory administrator email** to an active Workspace administrator who can read users in the Directory API. This account supplies directory access; each mailbox is read using that mailbox owner's delegated identity. +Under **Service account**, select **Add service account** and paste the JSON key into **Add Google Service Account**, or select an existing service account. Set **Directory administrator email** to an active Workspace administrator who can read users in the Directory API. This account supplies directory access; each mailbox is read using that mailbox owner's delegated identity. Keep the default **Date Range** of **Last 6 months**, or adjust it and **Labels**. Use label names or system IDs such as `INBOX`; custom `Label_…` IDs are mailbox-specific. A label that does not exist in one mailbox simply matches no threads there. @@ -104,9 +104,9 @@ Teammates join the Sim organization with their matching verified primary email. ## Source options -An admin opens **Settings → Sources** and selects **Manage** beside **Gmail** to open its configuration list. Each row shows **Member accounts** or **Service account** beside its sync status. Open a configuration's **Settings** tab to edit its filters, then select **Save**. Filters apply separately to each mailbox in the source. **Documents** shows indexed threads and **Sync history** shows recent runs. +An admin opens **Settings → Sources → Gmail** to open its configuration list. Each row shows **Member accounts** or **Service account** beside its sync status. Open a connection's **Settings** tab to edit its filters, then select **Save**. Filters apply separately to each mailbox in the connection. **Documents** shows indexed threads and **Sync history** shows recent runs. -**Sync using** identifies the configuration's fixed connection method. To replace a central credential, choose another **Indexing account** and select **Change indexing account**. +**Sync using** identifies the configuration's fixed connection method. To replace a central credential, choose another **Service account** and select **Change service account**. | Option | Behavior | | --- | --- | @@ -155,7 +155,7 @@ Member-account connections use the deployment's Google OAuth client below. Centr 1. In [Google Cloud Console](https://console.cloud.google.com/), select your project. Open **APIs & Services → Library**, find **Gmail API**, and enable it. 2. Open **Google Auth platform → Branding**. Select **Get started** if needed, then enter the app name, support email, and contact email. Under **Audience**, use **Internal** only for an app limited to your Google Workspace organization; otherwise use **External** and add test users while testing. Review the app's permissions under **Data Access → Add or remove scopes**, using the current Sim scopes below. Follow Google's [consent and verification guidance](https://developers.google.com/workspace/guides/configure-oauth-consent) for your audience. 3. Open **Google Auth platform → Clients → Create client**. Choose **Web application**, give the client a name, and add the URI below under **Authorized redirect URIs**. If this instance already has a Google client, add this URI to that client instead. See [Google's credential setup](https://developers.google.com/workspace/guides/create-credentials#web-application). -4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). If you change an existing deployment's OAuth client or scopes, an organization admin selects **Settings → Sources → More → Refresh connection settings**, then affected teammates reconnect. +4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). If you change an existing deployment's OAuth client or scopes, an organization admin selects **Settings → Sources → More → Update sign-in settings**, then affected teammates reconnect. ```text https:///api/auth/oauth2/callback/google-email diff --git a/apps/docs/content/docs/search/google-calendar.mdx b/apps/docs/content/docs/search/google-calendar.mdx index 37f427a369e..e9ff7efbbf6 100644 --- a/apps/docs/content/docs/search/google-calendar.mdx +++ b/apps/docs/content/docs/search/google-calendar.mdx @@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image' Search meetings and event details available to your Google account. Members can connect personal accounts, or a Google Workspace administrator can configure a central service-account crawl. Both paths keep each person's own view of events separate. -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**. +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. ## Choose your setup @@ -29,28 +29,28 @@ These are alternative setup paths. When only a central Calendar source is config ### Allow and connect Google Calendar -An admin opens **Settings → Sources** and turns on **Google Calendar**. Each person joins the Sim organization, opens **Integrations**, selects **Connect** beside Google Calendar, and authorizes the Google account matching their verified Sim email. The first connection creates the default member-account sync configuration. Return to Integrations to see indexing status and your searchable document count. +An admin selects **Settings → Sources → Add source → Google Calendar**, chooses **Sync using → Member accounts**, sets any filters, and selects **Set up member accounts**. Each person joins the Sim organization, opens **Integrations**, selects **Connect** beside Google Calendar, and authorizes the Google account matching their verified Sim email. Return to Integrations to see indexing status and your searchable document count. ### Choose calendars if needed -An admin selects **Manage** beside Google Calendar in **Settings → Sources**, opens **Advanced**, and selects the configuration's **Settings** tab. Leave **Calendars** empty to search each person's primary calendar. To include specific shared calendars, select an **Account for browsing** and choose calendars, or switch to **Calendar IDs** and enter their IDs. +An admin opens **Settings → Sources → Google Calendar**, selects the connection, then opens **Settings**. Leave **Calendars** empty to search each person's primary calendar. To include specific shared calendars, select an **Account for browsing** and choose calendars, or switch to **Calendar IDs** and enter their IDs. **Account for browsing** only helps you choose calendars. It does not connect your account for Search or grant teammates access. -### Save the configuration +### Save the connection -The default date range covers the previous and next 30 days. Save any changes to the existing configuration. Its calendar and date filters apply separately to each connected member's access. **Add sync configuration** opens the [central service-account setup](#set-up-a-central-service-account); it does not edit this member configuration. +The default date range covers the previous and next 30 days. Save any changes to the existing connection. Its calendar and date filters apply separately to each connected member's access. **Connect service account** opens the [central service-account setup](#set-up-a-central-service-account); it does not edit this member connection. -Admins can request member connections from **Manage → Accounts → Request connections**. These requests do not grant organization membership. Connecting Gmail or Drive does not replace the Calendar connection. See [Connect your account](/search/connect-your-account) for the shared connection and recovery steps. +Admins can request member connections from **Settings → Sources → People → Request connections** (filter by the integration first). These requests do not grant organization membership. Connecting Gmail or Drive does not replace the Calendar connection. See [Connect your account](/search/connect-your-account) for the shared connection and recovery steps. `primary` means the connected or impersonated person's main calendar. A calendar selected from the list is a specific calendar ID, even when it is your main calendar. That same ID applies to every selected user, and only users with access to it can search its events. @@ -58,7 +58,7 @@ Admins can request member connections from **Manage → Accounts → Request con ## Set up a central service account -Open **Settings → Sources** and turn on **Google Calendar**. Select **Manage → Advanced → Add sync configuration** to open the central service-account form. If personal connections are disabled for your organization, select **Add source** from the provider page instead. +Open **Settings → Sources → Add source** and select **Google Calendar**. This opens **Connect Google Calendar service account**. To add another connection later, open **Google Calendar** from the Sources list and select **Connect service account**. This requires a Google Workspace customer and a super administrator to authorize domain-wide delegation. Consumer Gmail accounts cannot use this path. @@ -93,7 +93,7 @@ The **Directory administrator email** must be an active Workspace administrator ### Add the credential and choose users -Under **Indexing account**, select **Add service account** or an existing service account. In **Add Google Service Account**, give the credential a name and paste its JSON key. Back in the source form, enter the **Directory administrator email**. +Under **Service account**, select **Add service account** or an existing service account. In **Add Google Service Account**, give the credential a name and paste its JSON key. Back in the source form, enter the **Directory administrator email**. Leave **Calendar IDs** empty for each user's `primary` calendar. To include shared calendars, enter their IDs, optionally alongside `primary`. IDs apply to each selected user who can read that calendar; this does not share calendars or expand anyone's Google access. Central setup uses manual IDs because an administrator's calendar picker would not represent every user's calendars. @@ -106,9 +106,9 @@ Sim verifies Directory access and selected users, then probes one selected user' ## Source options -An admin opens **Settings → Sources** and selects **Manage** beside **Google Calendar** to open its configuration list. Each row shows **Member accounts** or **Service account** beside its sync status. Open a configuration's **Settings** tab to edit its filters, then select **Save**. **Documents** shows indexed events and **Sync history** shows recent runs. +An admin opens **Settings → Sources → Google Calendar** to open its configuration list. Each row shows **Member accounts** or **Service account** beside its sync status. Open a connection's **Settings** tab to edit its filters, then select **Save**. **Documents** shows indexed events and **Sync history** shows recent runs. -**Sync using** identifies the configuration's fixed connection method. To replace a central credential, choose another **Indexing account** and select **Change indexing account**. +**Sync using** identifies the configuration's fixed connection method. To replace a central credential, choose another **Service account** and select **Change service account**. | Option | Behavior | | --- | --- | @@ -153,7 +153,7 @@ The deployment operator configures Google OAuth for member accounts and the memb 1. In [Google Cloud Console](https://console.cloud.google.com/), select your project. Open **APIs & Services → Library**, find **Google Calendar API**, and enable it. 2. Open **Google Auth platform → Branding** and configure the app name and contact details. Under **Audience**, choose **Internal** for your Google Workspace organization only, or **External** for other users. Add test users while an external app is testing. Review **Data Access → Add or remove scopes** using the current Sim scopes below. See Google's [consent and verification guidance](https://developers.google.com/workspace/guides/configure-oauth-consent). 3. Open **Google Auth platform → Clients → Create client**, choose **Web application**, and add the URI below under **Authorized redirect URIs**. Add it to the existing Google client if the instance already uses one. See [Google's credential setup](https://developers.google.com/workspace/guides/create-credentials#web-application). -4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). If you change an existing deployment's OAuth client or scopes, an organization admin selects **Settings → Sources → More → Refresh connection settings**, then affected teammates reconnect. +4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). If you change an existing deployment's OAuth client or scopes, an organization admin selects **Settings → Sources → More → Update sign-in settings**, then affected teammates reconnect. ```text https:///api/auth/oauth2/callback/google-calendar diff --git a/apps/docs/content/docs/search/google-drive.mdx b/apps/docs/content/docs/search/google-drive.mdx index 52ecfc4864b..0ba9cb742a2 100644 --- a/apps/docs/content/docs/search/google-drive.mdx +++ b/apps/docs/content/docs/search/google-drive.mdx @@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image' Search Google Docs, Sheets, Slides, and supported files in Drive. Members can connect their own accounts after an admin allows Google Drive, or an admin can configure a central service-account crawl. -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**. +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. ## Choose your setup @@ -31,36 +31,36 @@ These are alternative setup paths. When only a central Drive source is configure ### Allow Google Drive -An organization admin opens **Settings → Sources** and turns on **Google Drive**. This allows personal connections; it does not create a source or connect anyone's account. +An admin selects **Settings → Sources → Add source → Google Drive**, chooses **Sync using → Member accounts**, sets any filters, and selects **Set up member accounts**. ### Connect your account -Join the Sim organization, then open **Integrations** and select **Connect** beside Google Drive. Use the Google account matching your verified Sim email. The first personal connection can create a source with default filters. Return to Integrations to see indexing status and your searchable document count. +Join the Sim organization, then open **Integrations** and select **Connect** beside Google Drive. Use the Google account matching your verified Sim email. Return to Integrations to see indexing status and your searchable document count. ### Adjust filters if needed -An admin opens **Settings → Sources**, selects **Manage** beside **Google Drive**, opens **Advanced**, and selects the sync configuration's **Settings** tab. Leave **Folders** empty to include supported files each member can access, or narrow the source to folders. **Account for browsing** helps select folders; manual **Folder IDs** work without it. Browsing does not connect that account to Search. +An admin opens **Settings → Sources → Google Drive**, selects the connection, then opens **Settings**. Leave **Folders** empty to include supported files each member can access, or narrow the source to folders. **Account for browsing** helps select folders; manual **Folder IDs** work without it. Browsing does not connect that account to Search. Keep **Sync documents with → Connected members** unless a dedicated account should fetch content. Members still connect to establish access. If the dedicated account is a delegated service account, **Crawl as** selects the Google Workspace user whose files it fetches. Save the source settings when finished. -Admins can request member connections from **Manage → Accounts → Request connections**. These requests do not grant organization membership. See [Connect your account](/search/connect-your-account) for the shared connection and recovery steps. +Admins can request member connections from **Settings → Sources → People → Request connections** (filter by the integration first). These requests do not grant organization membership. See [Connect your account](/search/connect-your-account) for the shared connection and recovery steps. ## Set up a central service account -Open **Settings → Sources** and turn on **Google Drive**. Select **Manage → Advanced → Add sync configuration** to open the central service-account form directly. If personal connections are disabled for your organization, select **Add source** from the provider page instead. Teammates do not need a personal Drive connection for this source. +Open **Settings → Sources → Add source** and select **Google Drive**. This opens **Connect Google Drive service account**. To add another connection later, open **Google Drive** from the Sources list and select **Connect service account**. This requires a Google Workspace domain and a Workspace super administrator to authorize domain-wide delegation. Consumer Gmail accounts cannot use this path. -Google Drive central source setup with a service account, Directory administrator email, sharing policy, and optional folders +Google Drive setup with member and service-account methods, account selection, and folders @@ -101,7 +101,7 @@ Group permissions require groups and memberships that this administrator can rea ### Add the credential in Sim -Under **Indexing account**, choose **Add service account**, or select an existing service account. Paste the JSON key into **Add Google Service Account**, give it a name, and add it. Sim returns you to the source form with that credential selected. +Under **Service account**, choose **Add service account**, or select an existing service account. Paste the JSON key into **Add Google Service Account**, give it a name, and add it. Sim returns you to the source form with that credential selected. Add Google Service Account credential modal in Sim @@ -112,7 +112,7 @@ Under **Indexing account**, choose **Add service account**, or select an existin Set **Directory administrator email** to the Workspace administrator described above. Under **More options**, leave **Users** blank for everyone, or enter up to 100 primary Workspace email addresses separated by commas. Suspended, archived, and guest accounts are excluded. -Leave **Folders** empty to include supported files each selected user can access. To narrow the source, select folders visible to the Directory administrator or enter **Folder IDs** manually. The same folder filter applies to each selected user and does not grant access. Choose **Connect & Sync**. Sim validates the administrator and any selected users, and probes Drive access for an active user, before accepting the source. +Leave **Folders** empty to include supported files each selected user can access. To narrow the source, select folders visible to the Directory administrator or enter **Folder IDs** manually. The same folder filter applies to each selected user and does not grant access. Choose **Connect & Sync**. Sim validates the administrator and any selected users, and probes Drive access for an active user, before accepting the connection. The crawl pages through each user’s files and shared drives, including shared-drive files the user has never opened. It resumes unfinished work and indexes a shared file once even when several users can access it. A file must be downloadable by at least one selected user to be indexed; the same requirement applies to a shortcut’s target. Sim must also verify its permissions before showing it in Search. Google can let a reader download a file while refusing to list its permissions; include an owner or another user who can read those permissions. Externally owned files can remain hidden when no selected user can verify their permissions. @@ -123,9 +123,9 @@ Teammates join the Sim organization with their matching verified email; they do ## Source options -An admin opens **Settings → Sources** and selects **Manage** beside **Google Drive** to open its configuration list. Each row shows **Member accounts** or **Service account** beside its sync status. Open a configuration's **Settings** tab to edit its filters, then select **Save**. **Documents** shows indexed files and **Sync history** shows recent runs. +An admin opens **Settings → Sources → Google Drive** to open its configuration list. Each row shows **Member accounts** or **Service account** beside its sync status. Open a connection's **Settings** tab to edit its filters, then select **Save**. **Documents** shows indexed files and **Sync history** shows recent runs. -**Sync using** identifies the configuration's fixed connection method. To replace a central credential, choose another **Indexing account** and select **Change indexing account**. +**Sync using** identifies the configuration's fixed connection method. To replace a central credential, choose another **Service account** and select **Change service account**. | Option | Behavior | | --- | --- | @@ -139,7 +139,7 @@ An admin opens **Settings → Sources** and selects **Manage** beside **Google D Sim exports Docs and Slides as text and Sheets as XLSX spreadsheets. Supported uploaded files use the knowledge-base document pipeline, including PDF and Office formats. Unsupported files and oversized exports cannot be indexed; Google limits Workspace exports to 10 MB. See [Drive export formats](https://developers.google.com/workspace/drive/api/guides/ref-export-formats) and [download limits](https://developers.google.com/workspace/drive/api/guides/manage-downloads). -Search schedules syncs hourly. Central crawls revisit the selected users' files and permissions, including unchanged files, so permission changes and a new employee's older files are included. Unfinished crawls resume before deletion reconciliation. Content, deletions, and permissions refresh in the background; results are not a live read from Drive. **Accounts** on the provider page shows personal account connections where configured; it does not list the central service-account credential. +Search schedules syncs hourly. Central crawls revisit the selected users' files and permissions, including unchanged files, so permission changes and a new employee's older files are included. Unfinished crawls resume before deletion reconciliation. Content, deletions, and permissions refresh in the background; results are not a live read from Drive. **People** on the Sources settings page shows personal account connections; it does not list the central service-account credential. ## Troubleshooting @@ -168,7 +168,7 @@ https:///api/auth/oauth2/callback/google-drive This Google Cloud example uses one client for all three services. Replace `https://sim.example.com` with your Sim origin and add only the callbacks for services you enable. -Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). If you change an existing deployment's OAuth client or scopes, an organization admin selects **Settings → Sources → More → Refresh connection settings**, then affected teammates reconnect. +Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). If you change an existing deployment's OAuth client or scopes, an organization admin selects **Settings → Sources → More → Update sign-in settings**, then affected teammates reconnect. The current Sim Drive OAuth connection uses these scopes: diff --git a/apps/docs/content/docs/search/index.mdx b/apps/docs/content/docs/search/index.mdx index b3b9aa797be..f9203ead4e1 100644 --- a/apps/docs/content/docs/search/index.mdx +++ b/apps/docs/content/docs/search/index.mdx @@ -14,22 +14,18 @@ Search brings your connected sources into one place. An organization admin allow -### Allow an integration +### Choose an integration -As an organization admin, open **Settings → Sources** and turn on the integration. It stays in the list; no setup page opens automatically. - -The switch saves immediately and permits the integration in your organization. It does not connect an account, grant document access, or start indexing. Teammates connect from **Integrations** in the main sidebar; only admins manage these switches. +Open **Settings → Sources → Add source** and choose an integration. Unavailable integrations need deployment configuration before they can be connected. -### Connect or configure the source - -For personal Gmail, Calendar, or Drive, teammates can connect immediately from **Integrations**. The first account creates the default sync configuration. Admins can adjust it later under **Manage → Advanced** on the provider page. +### Connect the content -For other sources, select **Set up** or **Manage**, then **Add source**. For a central Gmail, Calendar, or Drive service account, use **Manage → Advanced → Add sync configuration**. For Slack, complete **Set up Slack app** first. Follow the provider's **Setup guide** to configure its connection and content. **More options** contains secondary filters and **Metadata tags**. +Follow the integration's **Setup guide**. Confluence connects a site and selected spaces; Jira adds projects; GitHub adds a repository; GitLab adds a project; Slack configures its app, then channels or DMs. Google service-account setup connects Drive, Gmail, or Calendar with the selected users and filters. -In the source form, select **Connect & Sync** for a central account or **Add source** for member accounts. Creating a source does not invite people or authorize their accounts. +Select **Connect & Sync** for a central account, or the form’s **Add** or **Set up member accounts** action for member accounts. **More options** contains additional filters. Saving a connection does not invite teammates or authorize their accounts. @@ -41,9 +37,9 @@ For member-account sources, open **Integrations** in the main sidebar and select -Organization Sources settings with provider switches and Set up or Manage actions +Organization Sources with Add source, connection status, People, and Stats -Source availability depends on the deployment and organization policy. An unavailable source needs operator configuration before setup can continue. If an existing provider asks for a configuration update after a deployment change, an admin selects **More → Refresh connection settings** on this page, then affected teammates reconnect. +Source availability depends on the deployment and organization policy. An unavailable source needs operator configuration before setup can continue. If an existing provider asks for a configuration update after a deployment change, an admin selects **More → Update sign-in settings** on this page, then affected teammates reconnect. ## Choose the right connection method @@ -57,7 +53,9 @@ Sources can use member accounts or a central connection. Gmail, Google Calendar, | **Confluence service account** | Connects an account that can read the selected content and its permissions. | Join the organization and connect Confluence to establish identity. | | **Administrator token** (GitLab) | Connects a self-managed instance administrator token and selects projects to index. | Join the organization with a verified Sim email matching GitLab. No personal connection is needed. | -Adding a Google or Confluence source from the admin page opens its central setup form directly. Google providers use **Manage → Advanced → Add sync configuration**; Confluence uses **Set up/Manage → Add source**. For personal connections, use **Integrations → Connect** in the main sidebar. An approved provider can create its first member source there; required repository, site, or project fields are collected before authorization. Admins can edit that source's filters afterward in its **Settings** tab. When personal connections are disabled for the organization, Google providers use **Add source** on the provider page instead of **Advanced**. +Open an integration from the Sources list to manage its connections. Use **Connect service account** for Google, **Add Confluence site**, **Add projects** for Jira, **Add repository** for GitHub, **Add project** for GitLab, or **Add channels or DMs** for Slack. + +For personal connections, teammates use **Integrations → Connect** in the main sidebar after the admin adds the provider. First-time setup may ask you to select content after connecting your account. Admins can edit those connections' filters afterward in **Settings**. Some member sources offer **Sync documents with**, either directly in setup or under **More options**. **Connected members** uses members' accounts for both content and access checks. Selecting a dedicated account uses it to fetch content; members still connect to establish which documents they may search. For GitHub organization sources, choose **Connect GitHub App** in this field to [connect an installation](/search/github#add-a-repository). **Account for browsing** only helps an admin pick source options—it does not enroll that account for Search. @@ -82,9 +80,9 @@ Some member sources offer **Sync documents with**, either directly in setup or u Invite people through the organization's **Settings → Members**, or use your organization's [SSO provisioning](/platform/enterprise/sso). Share the organization's **Home** or **Integrations** URL. People need their own Sim account and organization membership; they do not need access to a workspace. Connecting an external account alone does not grant organization membership. -To request a particular account connection, open **Settings → Sources**, select **Manage** beside the integration (or **Set up** if no sources exist), then **Accounts → Request connections**. This tab is available for providers that support personal connections. If setup is missing, complete the source or Slack app setup shown there first. The list shows organization account contributors and their connection status for that provider. +Open **Settings → Sources → People** to view account contributors and their connection status. Choose an integration in the filter to inspect its connections or send a targeted **Request connections** invitation. **All integrations** shows each person's connected providers and accounts needing reconnection. -These requests are separate from organization invitations. They let recipients contribute an external account; they do not grant Sim organization membership or Search access. **People** on the Sources settings page shows account contributors across providers. See [Connect your account](/search/connect-your-account) for the teammate walkthrough. +Connection requests do not grant organization membership. See [Connect your account](/search/connect-your-account) for the teammate walkthrough. **Revoke all account access** withdraws every account that person contributed to the organization, including other providers. It does not remove their organization membership. @@ -92,26 +90,28 @@ These requests are separate from organization invitations. They let recipients c ## Manage sources and documents -Open **Settings → Sources** and select **Manage** beside the integration. When personal connections are enabled, Gmail, Calendar, and Drive list their sync configurations under **Advanced** and connections under **Accounts**. Other providers with personal connections use **Sources** and **Accounts**. Providers without personal connections open directly to the source list. Select a source or sync configuration to manage it. Multiple sources can have different folder, repository, space, or project scopes. +Open **Settings → Sources**, choose the integration, then select its site, project, repository, or account connection. Each row identifies **Member accounts**, **Service account**, or **Admin account** and shows its sync status. -For Gmail, one configuration is usually enough. **Add sync configuration** creates another source; editing **Settings** updates the selected source. A member configuration applies to all connected Gmail accounts, including accounts connected later. A central configuration applies to the selected Workspace **Users**, or all active users when that field is blank. Its labels and filters are evaluated separately in each mailbox. +Edit **Settings** to change an existing connection's filters. Adding another connection creates a separate selection. For Gmail, one member-account connection is usually enough: its filters apply separately to each connected mailbox, including accounts connected later. A central Google connection applies to its selected **Users**, or all active users when that field is blank. -Gmail Advanced tab with a sync configuration, search, and Add sync configuration action +Gmail connections with the Connect service account action -| Source tab | What you can do | +| Connection tab | What you can do | | --- | --- | | **Documents** | Find indexed documents, inspect processing status, retry failed indexing, or exclude and restore documents. | | **Settings** | Edit the source's scope, filters, and supported indexing credentials. Select **Save** to apply changes or **Discard** to undo them. | | **Sync history** | Review run dates, document changes, and any sync or account errors. | -**Sync using** shows the method selected when the source was created. Create a new source to change that method. To replace a supported indexing credential, select its replacement and use **Change indexing account**. +**Sync using** shows the method selected when the source was created. Add a new connection to change that method. To replace a supported indexing credential, select its replacement and use **Change service account** or **Change account**, as shown. -Use the source header to sync, pause, resume, or remove that source. The back link returns to its integration. To disable an entire integration, turn off its switch in **Settings → Sources**. If it has sources, confirm **Deactivate**. Its content becomes unavailable in Search, Assistant, and MCP; sources and connected accounts are preserved. Turn the switch back on to allow it again. - -Gmail Settings with the read-only Sync using method and editable label, date range, and search filters +Use the connection header to sync, pause, resume, or remove it. To deactivate an entire integration, open it from **Settings → Sources**, select **Deactivate**, and confirm. Its content becomes unavailable in Search, Assistant, and MCP; saved connections remain. Select **Activate** on that integration to enable it again. Gmail Sync history showing run dates and document additions, deletions, or no changes +## Usage stats + +**Settings → Sources → Stats** shows successful Search invocations, active people, and results returned. Filter by entry point and a preset or custom date range of up to 90 days. Dates use UTC. Source usage counts requests that returned content from each integration; it is not a click or citation count. Tracking starts when enabled and does not backfill earlier activity. + ## Search, Assistant, and MCP **Search** in the organization sidebar finds documents directly. The assistant on **Home** can search and read the same sources to answer questions with citations. Conversations are private to their author, including when another organization member is an admin. @@ -135,7 +135,7 @@ Search runs background syncs on an hourly schedule. Large sources, provider limi A completed sync means the source was checked; some documents may still be indexing. Integrations lists personal connections; a central Google source can be searchable without appearing there. In the main **Integrations** page, each connected source row shows how many documents you can search and whether indexing failed for any documents you can access. -As an admin, open **Settings → Sources**, select **Manage** beside the integration, then open its source or sync configuration. In **Documents**, select **Failed** from the status dropdown to inspect those files. Use the search field to find a document by name. Select **Retry indexing** beside a file to try again. **Exclude** removes a file from search; select **Excluded** and then **Restore** to include it again. Fix a disconnected account or source configuration before retrying a sync that needs attention. +As an admin, open **Settings → Sources**, select the integration, then open its connection. In **Documents**, select **Failed** from the status dropdown to inspect those files. Use the search field to find a document by name. Select **Retry indexing** beside a file to try again. **Exclude** removes a file from search; select **Excluded** and then **Restore** to include it again. Fix a disconnected account or source configuration before retrying a sync that needs attention. Empty source Documents tab with search and an Included status filter diff --git a/apps/docs/content/docs/search/jira.mdx b/apps/docs/content/docs/search/jira.mdx index b793135c408..a50a7d6ffac 100644 --- a/apps/docs/content/docs/search/jira.mdx +++ b/apps/docs/content/docs/search/jira.mdx @@ -30,23 +30,18 @@ Search uses `read:jira-work`, `read:me`, and `offline_access` for issues, identi ### Choose Jira -Open your organization's **Settings → Sources**, turn on **Jira**, then select **Set up** (or **Manage**) → **Add source**. +Open **Settings → Sources → Add source** and select **Jira**. This opens **Add Jira projects**. To add another project selection later, open **Jira** from the Sources list and select **Add projects**. ### Choose the projects -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. +Under **Account for browsing**, select a saved account (including your personal Search account) or choose **Connect Jira account**. Enter **Jira site**, then choose **Projects**. **All** in the dropdown selects every project the account can currently browse; newly created projects are not added automatically. Clear the picker search before selecting all. -**Account for browsing** only loads the project picker, using OAuth or **Add service account**. It does not enroll anyone for Search. A service account needs Jira access and the [Jira read scopes](/integrations/atlassian-service-account#jira-and-jira-service-management); Confluence-only tokens do not work here. +To enter keys such as `ENG, SUPPORT` manually, use the switch beside **Projects**; this works without a browsing account. Switching between the picker and manual entry keeps your selection. -Jira source setup with an account for browsing, site domain, and projects +**Account for browsing** only loads the project picker, using OAuth or **Add service account**. It does not enroll anyone for Search. A service account needs Jira access and the [Jira read scopes](/integrations/atlassian-service-account#jira-and-jira-service-management); Confluence-only tokens do not work here. @@ -55,7 +50,7 @@ Enter **Jira Domain**. Under **Account for browsing**, select an account or choo 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. -Select **Add source**. This saves the shared scope; it does not authorize accounts or send invitations. +Select **Add projects**. This saves the shared scope; it does not authorize accounts or send invitations. @@ -71,13 +66,28 @@ Each teammate follows this step. Existing authorized accounts may already be con For workspace Search, start from **Search → Add source**; its source-creation button is **Create & Invite**. Search manages the sync schedule and hides item limits. -## Manage the source +## Connect before a source exists + +After an admin approves Jira, a teammate can start from **Integrations → Connect**. The same setup opens when you choose **Connect a different site or content scope**: + +1. Under **Your account**, select a saved account or choose **Connect account**. Authorize using the Atlassian email matching your verified Sim email. +2. Enter the hostname under **Atlassian site**, then choose **Projects**. Use **All** in the dropdown for the complete current list, or **Enter keys manually** for comma-separated keys. You can select up to 1,000 projects in this form. +3. Select **Connect & Sync**. Sim saves the selected scope and starts indexing with your account. + +Jira personal source setup with a saved account, Atlassian site, and selected projects + +## Manage project connections -Admins open **Settings → Sources → Jira → Manage**, then a source's **Documents**, **Settings**, or **Sync history**. Metadata tags include issue type, status, priority, labels, assignee, and last updated. +Admins open **Settings → Sources → Jira**, then a connection’s **Documents**, **Settings**, or **Sync history**. Metadata tags include issue type, status, priority, labels, assignee, and last updated. -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. +Teammates share the configured scope and do not choose projects again. For another site or scope, select **Add projects**, or use **Connect** beside **Connect a different site or content scope** in Integrations. -Invite teammates through **Settings → Members → Invite** or SSO, then have them connect Jira through **Integrations**. **Jira → Accounts → Request connections** only requests a provider connection; it does not invite people to the organization. +Invite teammates through **Settings → Members → Invite** or SSO, then have them connect Jira through **Integrations**. **Settings → Sources → People**, filter by **Jira**, then select **Request connections** only requests a provider connection; it does not invite people to the organization. Sim checks Jira separately for each connected person. Content becomes searchable as indexing finishes; issue changes and lost access are reflected after later syncs. @@ -104,6 +114,6 @@ Configure one shared Jira OAuth app for the deployment: 2. Under **Authorization → OAuth 2.0 (3LO)**, add `https:///api/auth/oauth2/callback/jira` as a callback. 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. +5. Connect from **Integrations** and select the configured site. After changing the OAuth client or requested scopes, use **Settings → Sources → More → Update sign-in settings**, then have affected teammates reconnect. The callback must match Sim's scheme, hostname, port, and path exactly. 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/docs/content/docs/search/slack.mdx b/apps/docs/content/docs/search/slack.mdx index 2b63bb88e82..11f17e1b687 100644 --- a/apps/docs/content/docs/search/slack.mdx +++ b/apps/docs/content/docs/search/slack.mdx @@ -23,7 +23,7 @@ Skip to **Connect the source** if the organization already has a verified Slack ### Open provider settings -Open **Settings → Sources** and turn on **Slack**. Select **Set up** (or **Manage** if sources already exist), then **Set up Slack app**. Allowing Slack does not configure the app or start indexing. +Open **Settings → Sources → Add source** and select **Slack**. Complete **Set up Slack app** before choosing messages to index. If Slack is already listed, open it and select **Set up Slack app**. @@ -53,14 +53,14 @@ Return to **Settings → Sources → Slack → Set up Slack app**. Select **Veri Member connections use one configured Slack app and workspace per organization. Installing another bot does not change that configuration. Changing the verified app requires members to reconnect. -After verification, open **Sources → Add source** on Slack’s provider page to choose what to index. A connected bot alone does not mean Slack messages have been indexed. +After verification, select **Add channels or DMs** on Slack’s provider page to choose what to index. A connected bot alone does not mean Slack messages have been indexed. ## Connect the source -Open **Sources → Add source** on Slack's provider page. Choose **Channel Messages**, **Direct Messages**, **Channels**, and **Earliest Message Date**. Open **More options** for exclusions, archived channels, metadata tags, and **Sync documents with**. Keep **Connected members** for the usual setup. +Open **Add channels or DMs** on Slack's provider page. Choose **Channel Messages**, **Direct Messages**, **Channels**, and **Earliest Message Date**. Open **More options** for exclusions, archived channels, metadata tags, and **Sync documents with**. Keep **Connected members** for the usual setup. | Field | Behavior | |---|---| @@ -71,15 +71,15 @@ Open **Sources → Add source** on Slack's provider page. Choose **Channel Messa | Archived Channels | Included by default. The picker lists active channels; use manual names/IDs to select archived channels. | | Earliest Message Date | Optional UTC date (`YYYY-MM-DD`). Applies to the thread's first message; replies are included with that thread. | -Select **Add source**. Each person opens **Integrations** in the main sidebar, selects **Connect** on the Slack source, and approves the configured app. Creating the source or verifying the Slack app does not authorize teammates automatically. +Select **Add channels or DMs**. Each person opens **Integrations** in the main sidebar, selects **Connect** on the Slack source, and approves the configured app. Creating the source or verifying the Slack app does not authorize teammates automatically. The Slack app's **Home → Connect sources** opens this same Integrations page. -To send a Slack connection request, open **Slack → Accounts → Request connections**. This requests an external account connection; it does not invite the recipient to the Sim organization. +To send a Slack connection request, open **Settings → Sources → People**, filter by **Slack**, then select **Request connections**. This requests an external account connection; it does not invite the recipient to the Sim organization. You can instead select an existing account under **Sync documents with** to supply message content centrally. Members still connect their own accounts to establish access. The selected account must itself be able to read the selected channels. -Admins open **Settings → Sources**, select **Manage** beside **Slack**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. Search reads indexed content, so source changes appear after background syncing. Slack retention and API limits determine how much history is available. +Admins open **Settings → Sources**, open **Slack**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. Search reads indexed content, so source changes appear after background syncing. Slack retention and API limits determine how much history is available. ## Permissions reference diff --git a/apps/docs/public/static/search/confluence-personal-setup.png b/apps/docs/public/static/search/confluence-personal-setup.png new file mode 100644 index 00000000000..f8c4119d62c Binary files /dev/null and b/apps/docs/public/static/search/confluence-personal-setup.png differ diff --git a/apps/docs/public/static/search/confluence-setup.png b/apps/docs/public/static/search/confluence-setup.png deleted file mode 100644 index 8ce6c182f59..00000000000 Binary files a/apps/docs/public/static/search/confluence-setup.png and /dev/null differ diff --git a/apps/docs/public/static/search/github-indexing-options.jpg b/apps/docs/public/static/search/github-indexing-options.jpg deleted file mode 100644 index ff4a800f808..00000000000 Binary files a/apps/docs/public/static/search/github-indexing-options.jpg and /dev/null differ diff --git a/apps/docs/public/static/search/github-setup.jpg b/apps/docs/public/static/search/github-setup.jpg deleted file mode 100644 index 454bd18b10e..00000000000 Binary files a/apps/docs/public/static/search/github-setup.jpg and /dev/null differ diff --git a/apps/docs/public/static/search/gitlab-options.jpg b/apps/docs/public/static/search/gitlab-options.jpg deleted file mode 100644 index c2ab696601f..00000000000 Binary files a/apps/docs/public/static/search/gitlab-options.jpg and /dev/null differ diff --git a/apps/docs/public/static/search/gitlab-setup.jpg b/apps/docs/public/static/search/gitlab-setup.jpg deleted file mode 100644 index eb2dccf93e7..00000000000 Binary files a/apps/docs/public/static/search/gitlab-setup.jpg and /dev/null differ diff --git a/apps/docs/public/static/search/google-drive-setup.jpg b/apps/docs/public/static/search/google-drive-setup.jpg deleted file mode 100644 index a61673d3495..00000000000 Binary files a/apps/docs/public/static/search/google-drive-setup.jpg and /dev/null differ diff --git a/apps/docs/public/static/search/google-drive-setup.png b/apps/docs/public/static/search/google-drive-setup.png new file mode 100644 index 00000000000..add1c737455 Binary files /dev/null and b/apps/docs/public/static/search/google-drive-setup.png differ diff --git a/apps/docs/public/static/search/integration-provider.jpg b/apps/docs/public/static/search/integration-provider.jpg deleted file mode 100644 index bf655343f51..00000000000 Binary files a/apps/docs/public/static/search/integration-provider.jpg and /dev/null differ diff --git a/apps/docs/public/static/search/integration-provider.png b/apps/docs/public/static/search/integration-provider.png new file mode 100644 index 00000000000..535a5860a03 Binary files /dev/null and b/apps/docs/public/static/search/integration-provider.png differ diff --git a/apps/docs/public/static/search/integration-settings.jpg b/apps/docs/public/static/search/integration-settings.jpg deleted file mode 100644 index f4e517c0656..00000000000 Binary files a/apps/docs/public/static/search/integration-settings.jpg and /dev/null differ diff --git a/apps/docs/public/static/search/integration-settings.png b/apps/docs/public/static/search/integration-settings.png new file mode 100644 index 00000000000..8dcffb53336 Binary files /dev/null and b/apps/docs/public/static/search/integration-settings.png differ diff --git a/apps/docs/public/static/search/jira-personal-setup.png b/apps/docs/public/static/search/jira-personal-setup.png new file mode 100644 index 00000000000..9530eb542d2 Binary files /dev/null and b/apps/docs/public/static/search/jira-personal-setup.png differ diff --git a/apps/docs/public/static/search/jira-setup.png b/apps/docs/public/static/search/jira-setup.png deleted file mode 100644 index 393b062c641..00000000000 Binary files a/apps/docs/public/static/search/jira-setup.png and /dev/null differ diff --git a/apps/docs/public/static/search/source-settings.jpg b/apps/docs/public/static/search/source-settings.jpg deleted file mode 100644 index d89c8062380..00000000000 Binary files a/apps/docs/public/static/search/source-settings.jpg and /dev/null differ diff --git a/apps/sim/app/api/knowledge/sim-search/personal-source-setup/route.ts b/apps/sim/app/api/knowledge/sim-search/personal-source-setup/route.ts new file mode 100644 index 00000000000..8fff7a8ff6e --- /dev/null +++ b/apps/sim/app/api/knowledge/sim-search/personal-source-setup/route.ts @@ -0,0 +1,70 @@ +import { + listPersonalSourceSetupAccountsContract, + personalSourceSetupContract, +} from '@/lib/api/contracts/knowledge/personal-source-setup' +import { + defineInternalJsonRoute, + extendInternalErrorPolicy, + internalErrorResponse, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + listPersonalSourceSetupAccounts, + personalSourceSetup, +} from '@/lib/knowledge/application/personal-source-setup' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission-check' + +const errorPolicy = extendInternalErrorPolicy( + internalKnowledgeErrorPolicies.connectAccount, + (error) => { + if (error instanceof SelectorConnectionUnavailableError) + return internalErrorResponse(error.status, { + error: 'Reconnect your account to choose projects or spaces', + }) + if (error instanceof SelectorContextUnavailableError) + return internalErrorResponse(400, { + error: 'Enter your Atlassian site to choose projects or spaces', + }) + if (error instanceof SelectorOptionsUnavailableError) + return internalErrorResponse(error.status, { + error: + 'Could not load projects or spaces. Check the site and account access, then try again.', + }) + if (error instanceof IntegrationNotAllowedError) + return internalErrorResponse(403, { error: error.message }) + return null + } +) + +export const GET = defineInternalJsonRoute({ + contract: listPersonalSourceSetupAccountsContract, + auth: internalSessionAuth, + operation: knowledgeOperations.listPersonalSourceSetupAccounts, + rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.personal-setup.accounts' }), + errorPolicy, + mapInput: ({ query }) => query, + useCase: listPersonalSourceSetupAccounts, + present: (data) => ({ success: true as const, data }), + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) + +export const POST = defineInternalJsonRoute({ + contract: personalSourceSetupContract, + auth: internalSessionAuth, + operation: knowledgeOperations.personalSourceSetup, + rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.personal-setup' }), + errorPolicy, + parseOptions: { maxBodyBytes: 384 * 1024 }, + mapInput: ({ body }) => body, + useCase: personalSourceSetup, + present: (data) => ({ success: true as const, data }), + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/api/knowledge/sim-search/stats/route.ts b/apps/sim/app/api/knowledge/sim-search/stats/route.ts new file mode 100644 index 00000000000..e7ae7eb9777 --- /dev/null +++ b/apps/sim/app/api/knowledge/sim-search/stats/route.ts @@ -0,0 +1,24 @@ +import { readOrganizationSearchStatsContract } from '@/lib/api/contracts/knowledge/search-stats' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { readOrganizationSearchStats } from '@/lib/knowledge/application/organization-search-stats' + +export const GET = defineInternalJsonRoute({ + contract: readOrganizationSearchStatsContract, + auth: internalSessionAuth, + operation: knowledgeOperations.readOrganizationSearchStats, + rateLimit: internalRateLimits.user({ + bucketName: 'organization-search-stats', + config: { maxTokens: 30, refillRate: 30, refillIntervalMs: 60_000 }, + }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ query }) => query, + useCase: readOrganizationSearchStats, + present: (data) => ({ success: true as const, data }), + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) 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 c5e75a3a749..fe091b7cc4f 100644 --- a/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx @@ -210,6 +210,7 @@ export function ConnectAccountOptions({ {enrollment.setupConnector && ( { } ) - it('keeps source setup fields after a failure without duplicating the toast inside the modal', async () => { + it('keeps manual 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 + mocks.setupConnector = + SEARCH_CONNECTORS.find((connector) => connector.type === 'github') ?? null await render() expect(document.querySelector('[role="dialog"]')).not.toBeNull() expect(document.body.textContent).not.toContain(message) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx new file mode 100644 index 00000000000..babcd4f2c91 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx @@ -0,0 +1,106 @@ +'use client' + +import { type ReactNode, useState } from 'react' +import { + ChipInput, + ChipModal, + ChipModalBody, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' +import { Search } from '@sim/emcn/icons' +import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import type { ConnectorMeta } from '@/connectors/types' + +interface AddOrganizationSourceModalProps { + sources: { + type: string + meta: ConnectorMeta + access: { admin: boolean; members: boolean } + }[] + pending: boolean + ready: boolean + feedback: ReactNode + onClose: () => void + onSelect: (type: string, accessMode: 'admin' | 'members') => void +} + +export function AddOrganizationSourceModal({ + sources, + pending, + ready, + feedback, + onClose, + onSelect, +}: AddOrganizationSourceModalProps) { + const [search, setSearch] = useState('') + const query = search.trim().toLowerCase() + const visible = sources.filter(({ meta }) => meta.name.toLowerCase().includes(query)) + + return ( + !open && onClose()} + srTitle='Add source' + > + Add source + + + setSearch(event.target.value)} + disabled={pending} + /> + + + {feedback} +
+ {visible.map(({ type, meta, access }) => { + const available = access.admin || access.members + return ( + } + title={meta.name} + description={ + !ready + ? 'Checking availability…' + : !available + ? 'Unavailable in this deployment' + : access.admin + ? meta.auth.mode === 'oauth' && + meta.auth.adminCredentialType === 'service_account' + ? 'Connect a service account' + : 'Connect an admin account' + : type === 'slack' + ? 'Set up your Slack app' + : 'Connect member accounts' + } + disabled={pending || !ready || !available} + onClick={() => onSelect(type, access.admin ? 'admin' : 'members')} + clickLabel={`Set up ${meta.name}`} + navigable={ready && available} + /> + ) + })} + {visible.length === 0 && ( + No matching sources + )} +
+
+
+ +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx index 952880c34ee..45b6307162e 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx @@ -23,6 +23,14 @@ vi.mock( '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup', () => ({ OrganizationIntegrationsSetup: () =>
Provider setup
}) ) +vi.mock( + '@/app/o/[organizationId]/settings/components/integrations/organization-source-stats', + () => ({ + OrganizationSourceStats: ({ organizationId }: { organizationId: string }) => ( +
Stats for {organizationId}
+ ), + }) +) vi.mock('@/hooks/queries/organization-accounts', () => ({ useOrganizationAccounts: mocks.accounts, useUpdateOrganizationAccounts: () => ({ @@ -53,6 +61,7 @@ describe('organization integration invitations', () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) mocks.context.mockReturnValue({ organization: { id: 'org-a' }, viewer: { isAdmin: true } }) mocks.accounts.mockReturnValue({ + isSuccess: true, data: { credentialGroup: { id: 'group-a', options: [] } }, error: null, refetch: mocks.refetch, @@ -111,9 +120,9 @@ describe('organization integration invitations', () => { ) ) const item = document.querySelector('[role="menuitem"]') - expect(item?.textContent).toBe('Refresh connection settings') + expect(item?.textContent).toBe('Update sign-in settings') await act(async () => item?.click()) - expect(document.body.textContent).toContain('Affected accounts will need to reconnect.') + expect(document.body.textContent).toContain('People whose settings changed must reconnect.') } it('keeps provider setup as the default and sends manual invitations from People to this org', async () => { @@ -147,6 +156,7 @@ describe('organization integration invitations', () => { it('refreshes saved provider identities only after choosing the maintenance action and confirming', async () => { mocks.accounts.mockReturnValue({ + isSuccess: true, data: { credentialGroup: { id: 'group-a', @@ -173,7 +183,7 @@ describe('organization integration invitations', () => { mocks.update.mockImplementationOnce((_input, { onSuccess }) => onSuccess()) await render() await openRefresh() - await click('Refresh') + await click('Update') expect(mocks.update).toHaveBeenCalledWith( { organizationId: 'org-a', @@ -198,26 +208,27 @@ describe('organization integration invitations', () => { }, expect.any(Object) ) - expect(toast.success).toHaveBeenCalledWith('Connection settings refreshed') + expect(toast.success).toHaveBeenCalledWith('Sign-in settings updated') expect(document.querySelector('[role="dialog"]')).toBeNull() }) it('keeps failed refreshes open for retry and blocks duplicate submissions', async () => { mocks.accounts.mockReturnValue({ + isSuccess: true, data: { credentialGroup: { id: 'group-a', options: [{ provider: 'gmail' }] } }, error: null, }) await render() await openRefresh() - await click('Refresh') + await click('Update') mocks.updateError = new Error('Update denied') await render() expect(document.body.textContent).toContain('Update denied') expect(document.querySelector('[role="dialog"]')).not.toBeNull() mocks.updatePending = true await render() - expect(findButton('Refresh')).toBeDisabled() + expect(findButton('Update')).toBeDisabled() expect(mocks.update).toHaveBeenCalledOnce() }) @@ -233,11 +244,16 @@ describe('organization integration invitations', () => { expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: true }) }) - it('loads people alongside setup but waits for the pool before allowing invitations', async () => { - mocks.accounts.mockReturnValue({ data: undefined, error: null, isPending: true }) - await render('?tab=people') + it('waits for integration options before loading filtered people or allowing invitations', async () => { + mocks.accounts.mockReturnValue({ + isSuccess: false, + data: undefined, + error: null, + isPending: true, + }) + await render('?tab=people&integration=jira') expect(mocks.accounts).toHaveBeenLastCalledWith('org-a') - expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: true }) + expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: false }) expect(container.textContent).toContain('Loading connected accounts') expect(container.textContent).not.toContain('No people invited yet') expect(findButton('Request connections')).toBeDisabled() @@ -245,10 +261,11 @@ describe('organization integration invitations', () => { expect(document.querySelector('[role="dialog"]')).toBeNull() mocks.accounts.mockReturnValue({ + isSuccess: true, data: { credentialGroup: { id: 'group-a', options: [] } }, error: null, }) - await render('?tab=people') + await render('?tab=people&integration=jira') expect(container.textContent).not.toContain('Loading connected accounts') expect(findButton('Request connections')).not.toBeDisabled() await click('Request connections') @@ -257,14 +274,23 @@ describe('organization integration invitations', () => { }) it('stops the people query when setup resolves without a pool and preserves the setup action', async () => { - mocks.accounts.mockReturnValue({ data: undefined, error: null, isPending: true }) + mocks.accounts.mockReturnValue({ + isSuccess: false, + data: undefined, + error: null, + isPending: true, + }) mocks.people.mockReturnValue({ error: new Error('Organization accounts not configured') }) - await render('?tab=people') - expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: true }) + await render('?tab=people&integration=jira') + expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: false }) expect(container.textContent).not.toContain('Organization accounts not configured') - mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, error: null }) - await render('?tab=people') + mocks.accounts.mockReturnValue({ + isSuccess: true, + data: { credentialGroup: null }, + error: null, + }) + await render('?tab=people&integration=jira') expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: false }) expect(container.textContent).toContain('before requesting connections') expect(container.textContent).not.toContain('Organization accounts not configured') @@ -272,7 +298,11 @@ describe('organization integration invitations', () => { }) it('sends an org without a credential group back to provider setup before invitations', async () => { - mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, error: null }) + mocks.accounts.mockReturnValue({ + isSuccess: true, + data: { credentialGroup: null }, + error: null, + }) await render('?tab=people') expect(container.textContent).toContain('before requesting connections') expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: false }) @@ -284,6 +314,7 @@ describe('organization integration invitations', () => { it('surfaces account lookup errors instead of treating them as missing setup', async () => { mocks.accounts.mockReturnValue({ + isSuccess: true, error: new Error('Account access denied'), refetch: mocks.refetch, }) @@ -304,4 +335,105 @@ describe('organization integration invitations', () => { expect(mocks.people).not.toHaveBeenCalled() expect(mocks.invite).not.toHaveBeenCalled() }) + it('opens organization stats without loading people', async () => { + await render() + await click('Stats') + expect(container.textContent).toContain('Stats for org-a') + expect(mocks.people).not.toHaveBeenCalled() + }) + + it('filters connection summaries and requests to the selected integration, then returns to All', async () => { + mocks.accounts.mockReturnValue({ + isSuccess: true, + data: { + credentialGroup: { + id: 'group-a', + options: [ + { id: 'jira-option', provider: 'jira', status: 'active' }, + { id: 'gmail-option', provider: 'gmail', status: 'active' }, + { id: 'old-option', provider: 'confluence', status: 'revoked' }, + ], + }, + }, + }) + await render('?tab=people&integration=jira&credential-group-people=alex') + expect(mocks.people).toHaveBeenLastCalledWith('org-a', 'alex', { + enabled: true, + optionId: 'jira-option', + }) + expect(findButton('Filter people by integration').textContent).toContain('Jira') + await click('Request connections') + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + 'Request Jira connections' + ) + await click('Cancel') + await act(async () => + findButton('Filter people by integration').dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true, button: 0 }) + ) + ) + const all = Array.from(document.querySelectorAll('[role="menuitem"]')).find( + (item) => item.textContent === 'All integrations' + ) + expect(all).toBeDefined() + expect(document.querySelector('[role="menu"]')?.textContent).not.toContain('Confluence') + await act(async () => all?.click()) + await vi.waitFor(() => + expect(mocks.people).toHaveBeenLastCalledWith('org-a', 'alex', { enabled: true }) + ) + expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') + }) + + it.each(['', '&integration=gmail'])( + 'defaults to All on navigation with one integration and initial filter %s', + async (filter) => { + mocks.accounts.mockReturnValue({ + isSuccess: true, + data: { + credentialGroup: { + id: 'group-a', + options: [{ id: 'gmail-option', provider: 'gmail', status: 'active' }], + }, + }, + }) + await render(`?tab=people${filter}`) + expect(findButton('Filter people by integration').textContent).toContain( + filter ? 'Gmail' : 'All integrations' + ) + expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { + enabled: true, + ...(filter ? { optionId: 'gmail-option' } : {}), + }) + await click('Sources') + await click('People') + expect(findButton('Filter people by integration').textContent).toContain('All integrations') + expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { enabled: true }) + } + ) + + it('preserves Slack setup recovery in People without hiding existing connections', async () => { + mocks.accounts.mockReturnValue({ + isSuccess: true, + data: { + credentialGroup: { + id: 'group-a', + options: [ + { + id: 'slack-option', + provider: 'slack', + status: 'active', + configurationStatus: 'needs_update', + }, + ], + }, + }, + }) + await render('?tab=people&integration=slack') + expect(mocks.people).toHaveBeenLastCalledWith('org-a', '', { + enabled: true, + optionId: 'slack-option', + }) + expect(findButton('Request connections')).toBeDisabled() + expect(container.textContent).toContain('Update the Slack app from Sources') + }) }) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx index 5a88385585a..4753f09eb96 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx @@ -2,17 +2,21 @@ import { useState } from 'react' import { Chip, ChipConfirmModal, ChipModalError, ChipSwitch, toast } from '@sim/emcn' -import { useQueryState } from 'nuqs' +import { useQueryStates } from 'nuqs' import { getOrganizationAccountUpdateOptions } from '@/lib/credential-groups/organization-account-options' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { OrganizationIntegrationsSetup } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup' -import { organizationIntegrationsTabParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params' +import { OrganizationSourcePeople } from '@/app/o/[organizationId]/settings/components/integrations/organization-source-people' +import { OrganizationSourceStats } from '@/app/o/[organizationId]/settings/components/integrations/organization-source-stats' +import { + organizationIntegrationsTabParam, + organizationPeopleIntegrationParam, +} from '@/app/o/[organizationId]/settings/components/integrations/search-params' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState, SettingsQueryErrorState, } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people' import { useOrganizationAccounts, useUpdateOrganizationAccounts, @@ -20,10 +24,10 @@ import { export function OrganizationIntegrationsSettings() { const { organization, viewer } = useOrganizationContext() - const [tab, setTab] = useQueryState( - organizationIntegrationsTabParam.key, - organizationIntegrationsTabParam.parser - ) + const [{ tab }, setNavigation] = useQueryStates({ + [organizationIntegrationsTabParam.key]: organizationIntegrationsTabParam.parser, + [organizationPeopleIntegrationParam.key]: organizationPeopleIntegrationParam.parser, + }) const accounts = useOrganizationAccounts(viewer.isAdmin ? organization.id : undefined) const update = useUpdateOrganizationAccounts() const [refreshOpen, setRefreshOpen] = useState(false) @@ -39,58 +43,68 @@ export function OrganizationIntegrationsSettings() { { onSuccess: () => { setRefreshOpen(false) - toast.success('Connection settings refreshed') + toast.success('Sign-in settings updated') }, } ) } if (!viewer.isAdmin) return null + const tabs = ( + void setNavigation({ tab: value, integration: null })} + options={[ + { value: 'providers', label: 'Sources' }, + { value: 'people', label: 'People' }, + { value: 'stats', label: 'Stats' }, + ]} + /> + ) + return (
-
- void setTab(value)} - options={[ - { value: 'providers', label: 'Sources' }, - { value: 'people', label: 'People' }, - ]} - /> - {tab === 'providers' && !accounts.error && group && group.options.length > 0 && ( - { - update.reset() - setRefreshOpen(true) + {tab === 'providers' && ( +
+ {tabs} + {tab === 'providers' && !accounts.error && group && group.options.length > 0 && ( + { + update.reset() + setRefreshOpen(true) + }, }, - }, - ]} - /> - )} -
+ ]} + /> + )} +
+ )} { if (!update.isPending) setRefreshOpen(open) }} - title='Refresh connection settings?' - text='Apply the latest sign-in settings to all integrations. Affected accounts will need to reconnect.' - confirm={{ label: 'Refresh', pending: update.isPending, onClick: refreshConnections }} + title='Update sign-in settings?' + text='Apply Sim’s current OAuth app and permission settings to member connections. People whose settings changed must reconnect. This does not sync content.' + confirm={{ label: 'Update', pending: update.isPending, onClick: refreshConnections }} > {update.error?.message} {tab === 'providers' && } + {tab === 'stats' && } {tab === 'people' && ( - Add a source that uses member accounts before requesting connections. - void setTab('providers')}>View sources + void setNavigation({ tab: 'providers', integration: null })}> + View sources +
) : undefined } diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx index 9c1f75f361e..bb3eab8e511 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx @@ -8,6 +8,9 @@ import type { OrganizationSearchProviderSummary } from '@/lib/api/contracts/know const mocks = vi.hoisted(() => ({ admin: true, + memberAccess: true, + mirroredAccess: true, + updateUrl: vi.fn(), overview: vi.fn(), push: vi.fn(), add: vi.fn(), @@ -25,7 +28,7 @@ vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ useOrganizationContext: () => ({ organization: { id: 'org-one' }, viewer: { isAdmin: mocks.admin }, - searchAccess: { memberScoped: true, sourceMirrored: true }, + searchAccess: { memberScoped: mocks.memberAccess, sourceMirrored: mocks.mirroredAccess }, }), })) vi.mock('@/hooks/queries/kb/connectors', () => ({ useOrganizationSearchOverview: mocks.overview })) @@ -73,6 +76,8 @@ let container: HTMLDivElement beforeEach(() => { vi.clearAllMocks() mocks.admin = true + mocks.memberAccess = true + mocks.mirroredAccess = true mocks.pending = false mocks.mutationError = null mocks.add.mockReset() @@ -83,6 +88,7 @@ beforeEach(() => { ['confluence', true], ['google-drive', true], ['google-email', true], + ['jira', true], ]), isIntegrationAvailabilityReady: true, refetchIntegrationAvailability: mocks.refetch, @@ -110,7 +116,7 @@ afterEach(async () => { async function render(searchParams = '') { await act(async () => root.render( - + @@ -128,253 +134,280 @@ async function click(label: string) { await act(async () => button!.click()) } -describe('organization integration management entry', () => { - it('uses Sources terminology in search and its empty state', async () => { - await render('?search=not-a-real-source') - expect(container.querySelector('input[placeholder="Search sources..."]')).toHaveValue( - 'not-a-real-source' - ) - expect(container.textContent).toContain('No matching sources') - expect(container.textContent).not.toContain('No matching integrations') - }) - it('offers Drive account management before anyone has connected', async () => { - mocks.overview.mockReturnValue({ - data: { - providers: [ - { - connectorType: 'google_drive', - approved: true, - sourceCount: 0, - status: 'waiting_for_connections', - issue: null, - isSyncing: false, - }, - ], - }, - isPending: false, +async function expectSetup(type: string | null, access: string | null = null) { + await act(async () => { + await vi.waitFor(() => { + const params = mocks.updateUrl.mock.calls.at(-1)?.[0].searchParams + expect(params).toBeDefined() + expect(params.get('addConnector')).toBe(type) + expect(params.get('source-access')).toBe(access) }) - await render() - expect(document.querySelector('a[aria-label="Manage Google Drive"]')).toHaveAttribute( - 'href', - '/o/org-one/settings/integrations/providers/google_drive' - ) - expect(document.querySelector('a[aria-label="Set up Google Drive"]')).toBeNull() - expect(container.textContent).toContain('Waiting for connections') }) - it('shows the stable catalog with switches and separate setup and management links', async () => { +} + +async function fillPicker(value: string) { + const input = document.querySelector('[aria-label="Find a source"]') + expect(input).not.toBeNull() + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value) + input?.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +describe('organization integration management entry', () => { + it('uses navigable settings rows for added sources without switches or setup buttons', async () => { await render() - expect(document.querySelector('a[aria-label="Manage Gmail"]')).toHaveAttribute( + expect(container.textContent).toContain('Waiting for connections') + expect(container.textContent).toContain('Deactivated · 2 connections') + expect(container.querySelector('a[aria-label="Manage Gmail"]')).toHaveAttribute( 'href', '/o/org-one/settings/integrations/providers/gmail' ) - expect(document.querySelector('a[aria-label="Manage Google Drive"]')).toHaveAttribute( - 'href', - '/o/org-one/settings/integrations/providers/google_drive' - ) - expect(container.textContent).toContain('Waiting for connections') - expect(container.textContent).not.toContain('Needs setup') - expect(container.textContent).toContain('Disabled') - expect(container.textContent).toContain('Confluence') - expect(container.textContent).not.toContain('Add integration') - expect(document.querySelector('[aria-label="Allow Gmail in Sim Search"]')).toHaveAttribute( - 'aria-checked', - 'true' - ) - expect( - document.querySelector('[aria-label="Allow Google Drive in Sim Search"]') - ).toHaveAttribute('aria-checked', 'false') - expect(document.querySelector('a[aria-label="Set up Confluence"]')).toBeNull() - }) - - it('offers the catalog for a new organization without a modal or writes', async () => { - mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) - await render() - expect(container.textContent).toContain('Confluence') - expect(container.textContent).toContain('Google Drive') - expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(container.querySelector('a[aria-label="Manage Google Drive"]')).not.toBeNull() + expect(container.textContent).not.toContain('Confluence') + expect(container.querySelector('[role="switch"]')).toBeNull() + expect(container.querySelector('a[aria-label="Set up Gmail"]')).toBeNull() expect(mocks.add).not.toHaveBeenCalled() }) - it('enables in place and shows setup after the server confirms approval', async () => { - await render() - const row = document.querySelector('[aria-label="Allow Confluence in Sim Search"]') - await click('Allow Confluence in Sim Search') - expect(mocks.add).toHaveBeenCalledExactlyOnceWith( - { organizationId: 'org-one', connectorType: 'confluence', approved: true }, - expect.any(Object) - ) - expect(row).toHaveAttribute('aria-checked', 'false') + it('keeps approved sources with unfinished setup visible', async () => { mocks.overview.mockReturnValue({ data: { providers: [ - ...providers, { + ...providers[0], connectorType: 'confluence', - approved: true, - sourceCount: 0, status: 'needs_setup', - isSyncing: false, }, ], }, isPending: false, }) await render() - expect(document.querySelector('[aria-label="Allow Confluence in Sim Search"]')).toBe(row) - expect(row).toHaveAttribute('aria-checked', 'true') - expect(document.querySelector('a[aria-label="Set up Confluence"]')).not.toBeNull() - expect(mocks.push).not.toHaveBeenCalled() - expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(container.textContent).toContain('Setup required') + expect(container.querySelector('a[aria-label="Manage Confluence"]')).not.toBeNull() }) - it('requires confirmation before disabling existing sources and lets the admin cancel', async () => { - mocks.overview.mockReturnValue({ - data: { providers: [{ ...providers[1], approved: true }] }, - isPending: false, - }) + it('opens the catalog from the header without changing any approvals', async () => { + mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) await render() - await click('Allow Google Drive in Sim Search') - expect(document.querySelector('[role="dialog"]')).toHaveTextContent( - 'Sources and connected accounts are preserved.' - ) + expect(container.textContent).toContain('No sources yet. Add a source to get started.') + expect(document.querySelector('[role="dialog"]')).toBeNull() + await click('Add source') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Confluence') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Jira') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Connect a service account') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Connect member accounts') expect(mocks.add).not.toHaveBeenCalled() await click('Cancel') expect(document.querySelector('[role="dialog"]')).toBeNull() + await expectSetup(null) expect(mocks.add).not.toHaveBeenCalled() - await click('Allow Google Drive in Sim Search') - mocks.add.mockImplementation((_input, options) => options.onSuccess()) - await click('Deactivate') + }) + + it('approves a new source before opening its central configuration form', async () => { + await render('?addConnector=') + await click('Set up Confluence') expect(mocks.add).toHaveBeenCalledExactlyOnceWith( - { organizationId: 'org-one', connectorType: 'google_drive', approved: false }, + { organizationId: 'org-one', connectorType: 'confluence', approved: true }, expect.any(Object) ) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + await act(async () => mocks.add.mock.calls[0][1].onSuccess()) + await expectSetup('confluence') expect(document.querySelector('[role="dialog"]')).toBeNull() }) - it('allows disabling an integration with no sources in place', async () => { - await render() - await click('Allow Gmail in Sim Search') - expect(mocks.add).toHaveBeenCalledExactlyOnceWith( - { organizationId: 'org-one', connectorType: 'gmail', approved: false }, + it('opens an approved source directly without a duplicate approval', async () => { + await render('?addConnector=&search=retained') + await click('Set up Gmail') + await expectSetup('gmail') + expect(mocks.updateUrl.mock.calls.at(-1)?.[0].searchParams.get('search')).toBe('retained') + expect(mocks.add).not.toHaveBeenCalled() + }) + + it.each(['jira', 'confluence'])( + 'uses member setup for %s when central indexing is unavailable', + async (type) => { + mocks.mirroredAccess = false + mocks.add.mockImplementation((_input, options) => options.onSuccess()) + await render('?addConnector=') + await click(`Set up ${type === 'jira' ? 'Jira' : 'Confluence'}`) + await expectSetup(type, 'members') + } + ) + + it('routes Slack into the existing member setup with its custom-app step', async () => { + mocks.availability.mockReturnValue({ + integrationAvailability: new Map([['slack_v2', { state: 'limited' }]]), + oauthServiceAvailability: new Map(), + isIntegrationAvailabilityReady: true, + }) + mocks.add.mockImplementation((_input, options) => options.onSuccess()) + await render('?addConnector=') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Set up your Slack app') + await click('Set up Slack') + await expectSetup('slack', 'members') + }) + + it('reactivates a retained source before opening another configuration', async () => { + mocks.add.mockImplementation((_input, options) => options.onSuccess()) + await render('?addConnector=') + await click('Set up Google Drive') + await expectSetup('google_drive') + expect(mocks.add).toHaveBeenCalledWith( + { organizationId: 'org-one', connectorType: 'google_drive', approved: true }, expect.any(Object) ) - expect(document.querySelector('[role="dialog"]')).toBeNull() }) - it('keeps the confirmed state and reports failed approval changes', async () => { + it('keeps the picker open and toasts a failed approval for retry', async () => { mocks.add.mockImplementation((_input, options) => - options.onError(new Error('Could not update integration')) + options.onError(new Error('Could not add source')) ) - await render() - await click('Allow Confluence in Sim Search') - expect(toast.error).toHaveBeenCalledWith('Could not update integration') - expect(document.querySelector('[aria-label="Allow Confluence in Sim Search"]')).toHaveAttribute( - 'aria-checked', - 'false' - ) - expect(document.querySelector('a[aria-label="Set up Confluence"]')).toBeNull() + await render('?addConnector=') + await click('Set up Confluence') + expect(toast.error).toHaveBeenCalledWith('Could not add source') + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + expect(mocks.updateUrl).not.toHaveBeenCalled() }) - it('keeps a failed deactivation in the confirmation dialog for retry', async () => { - mocks.overview.mockReturnValue({ - data: { providers: [{ ...providers[1], approved: true }] }, - isPending: false, + it('prevents another selection or dismissal while approval is pending', async () => { + mocks.pending = true + await render('?addConnector=') + expect(document.querySelector('button[aria-label="Set up Confluence"]')).toBeNull() + expect(document.querySelector('[aria-label="Find a source"]')).toBeDisabled() + await click('Cancel') + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + expect(mocks.add).not.toHaveBeenCalled() + expect(mocks.updateUrl).not.toHaveBeenCalled() + }) + + it('explains unavailable providers in the picker without allowing setup', async () => { + mocks.availability.mockReturnValue({ + integrationAvailability: new Map(), + oauthServiceAvailability: new Map(), + isIntegrationAvailabilityReady: true, }) - await render() - await click('Allow Google Drive in Sim Search') - await click('Deactivate') - mocks.mutationError = new Error('Could not deactivate integration') - await render() + await render('?addConnector=') expect(document.querySelector('[role="dialog"]')).toHaveTextContent( - 'Could not deactivate integration' + 'Unavailable in this deployment' ) - expect( - document.querySelector('[aria-label="Allow Google Drive in Sim Search"]') - ).toHaveAttribute('aria-checked', 'true') - }) - - it('prevents duplicate changes while a mutation is pending', async () => { - mocks.pending = true - await render() - await click('Allow Confluence in Sim Search') + expect(document.querySelector('button[aria-label="Set up Confluence"]')).toBeNull() expect(mocks.add).not.toHaveBeenCalled() - expect(document.querySelector('[aria-label="Allow Confluence in Sim Search"]')).toBeDisabled() }) - it('blocks unavailable setup while keeping existing sources manageable and deactivatable', async () => { + it('keeps existing unavailable sources manageable', async () => { mocks.availability.mockReturnValue({ integrationAvailability: new Map(), oauthServiceAvailability: new Map(), isIntegrationAvailabilityReady: true, }) + await render() + expect(container.querySelector('a[aria-label="Manage Gmail"]')).not.toBeNull() + expect(container.querySelector('a[aria-label="Manage Google Drive"]')).not.toBeNull() + }) + + it('filters the picker independently from the main source list and resets when reopened', async () => { + await render('?search=gmail') + await click('Add source') + await fillPicker('confluence') + const dialog = document.querySelector('[role="dialog"]') + expect(dialog).toHaveTextContent('Confluence') + expect(dialog).not.toHaveTextContent('Google Drive') + expect(container.querySelector('a[aria-label="Manage Gmail"]')).not.toBeNull() + await fillPicker('no-such-source') + expect(dialog).toHaveTextContent('No matching sources') + await click('Cancel') + await click('Add source') + expect(document.querySelector('[aria-label="Find a source"]')).toHaveValue('') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Google Drive') + expect(mocks.add).not.toHaveBeenCalled() + }) + + it('keeps source health visible alongside the configuration count', async () => { mocks.overview.mockReturnValue({ - data: { providers: [{ ...providers[1], approved: true }] }, + data: { + providers: [ + { + ...providers[1], + approved: true, + status: 'needs_attention', + issue: 'sync_failed', + }, + ], + }, isPending: false, }) await render() - expect(document.querySelector('[aria-label="Allow Confluence in Sim Search"]')).toBeDisabled() - expect( - document.querySelector('[aria-label="Allow Google Drive in Sim Search"]') - ).not.toBeDisabled() - expect(document.querySelector('a[aria-label="Manage Google Drive"]')).not.toBeNull() - expect(container.textContent).toContain('Unavailable in this deployment') - await click('Allow Confluence in Sim Search') - expect(mocks.add).not.toHaveBeenCalled() + expect(container.textContent).toContain('Sync failed · 2 connections') }) - it('fails closed while availability loads and offers retry if it fails', async () => { + it('fails closed and offers retry when availability fails inside the picker', async () => { mocks.availability.mockReturnValue({ integrationAvailability: new Map(), oauthServiceAvailability: new Map(), - isIntegrationAvailabilityReady: false, + isIntegrationAvailabilityReady: true, integrationAvailabilityError: new Error('Could not load connection availability'), refetchIntegrationAvailability: mocks.refetch, }) - await render() - expect(document.querySelector('[aria-label="Allow Confluence in Sim Search"]')).toBeDisabled() + await render('?addConnector=') + expect(document.querySelector('[role="dialog"]')).toHaveTextContent( + 'Could not load connection availability' + ) + expect(document.querySelector('button[aria-label="Set up Confluence"]')).toBeNull() await click('Try again') expect(mocks.refetch).toHaveBeenCalledOnce() }) - it('does not hide actionable sync failures behind a source count', async () => { - mocks.overview.mockReturnValue({ - data: { providers: [{ ...providers[1], approved: true, status: 'needs_attention' }] }, - isPending: false, - }) - await render() - expect(container.textContent).toContain('Sync failed') - expect(document.querySelector('a[aria-label="Manage Google Drive"]')).not.toBeNull() - }) - - it('does not turn a failed overview into unapproved switches', async () => { + it('does not turn a failed overview into an empty source list', async () => { mocks.overview.mockReturnValue({ error: new Error('Could not load sources'), isError: true, refetch: mocks.refetch, }) await render() - expect(document.querySelector('[role="switch"]')).toBeNull() + expect(container.textContent).not.toContain('No sources yet') + expect( + Array.from(container.querySelectorAll('button')).find( + (node) => node.textContent === 'Add source' + ) + ).toBeDisabled() await click('Try again') expect(mocks.refetch).toHaveBeenCalledOnce() }) - it('keeps loading distinct from unapproved integrations', async () => { + it('keeps loading distinct from a new organization', async () => { mocks.overview.mockReturnValue({ isPending: true }) await render() expect(container.textContent).toContain('Loading sources') - expect(document.querySelector('[role="switch"]')).toBeNull() + expect(container.textContent).not.toContain('No sources yet') + expect( + Array.from(container.querySelectorAll('button')).find( + (node) => node.textContent === 'Add source' + ) + ).toBeDisabled() }) it('does not expose admin controls or load admin data for members', async () => { mocks.admin = false - await render() + await render('?addConnector=') expect(mocks.overview).toHaveBeenLastCalledWith('org-one', { enabled: false }) expect(container.textContent).toBe('') + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + + it('does not expose setup when Search is disabled', async () => { + mocks.memberAccess = false + mocks.mirroredAccess = false + await render('?addConnector=') + expect(container.textContent).toContain('Search sources are not enabled') + expect(document.querySelector('[role="dialog"]')).toBeNull() }) - it('filters the whole catalog without changing approvals', async () => { - await render('?search=confluence') - expect(container.textContent).toContain('Confluence') + it('filters added sources without changing approvals', async () => { + await render('?search=gmail') + expect(container.textContent).toContain('Gmail') expect(container.textContent).not.toContain('Google Drive') expect(mocks.add).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx index a3de964ef15..359388941bd 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx @@ -1,19 +1,21 @@ 'use client' -import { useState } from 'react' -import { ChipConfirmModal, ChipLink, ChipModalError, Switch, toast } from '@sim/emcn' +import { toast } from '@sim/emcn' +import { Plus } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' import { SettingsPanel } from '@/components/settings/settings-panel' import { organizationRoutes } from '@/lib/navigation/paths' -import { - canConnectWithDefaults, - getConnectorAccessAvailability, - SEARCH_SOURCE_TYPES, -} from '@/lib/sim-search/connectors' +import { getConnectorAccessAvailability, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { AddOrganizationSourceModal } from '@/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal' import { organizationSearchStatusLabel } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-status' import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup' +import { + searchSetupAccessParam, + searchSetupParam, +} from '@/app/workspace/[workspaceId]/search/search-params' import { SettingsEmptyState, SettingsQueryErrorState, @@ -30,25 +32,62 @@ import { usePermissionConfig } from '@/hooks/use-permission-config' export function OrganizationIntegrationsSetup() { const { organization, viewer, searchAccess } = useOrganizationContext() const [search, setSearch] = useSettingsSearch() + const [setup, setSetup] = useQueryStates( + { + [searchSetupParam.key]: searchSetupParam.parser, + [searchSetupAccessParam.key]: searchSetupAccessParam.parser, + }, + { history: 'replace' } + ) const overview = useOrganizationSearchOverview(organization.id, { enabled: viewer.isAdmin }) const availability = usePermissionConfig() const approval = useUpdateSearchIntegration() - const [deactivating, setDeactivating] = useState(null) const providers = new Map( overview.data?.providers.map((provider) => [provider.connectorType, provider]) ) + const sources = SEARCH_SOURCE_TYPES.map(([type, meta]) => ({ + type, + meta, + access: getConnectorAccessAvailability(meta, availability.integrationAvailability, { + memberAccessAvailable: searchAccess.memberScoped, + mirroredAccessAvailable: searchAccess.sourceMirrored, + oauthServiceAvailability: availability.oauthServiceAvailability, + isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady, + }), + })) const query = search.trim().toLowerCase() - const visible = SEARCH_SOURCE_TYPES.filter(([, meta]) => meta.name.toLowerCase().includes(query)) - const deactivatingName = SEARCH_SOURCE_TYPES.find(([type]) => type === deactivating)?.[1].name - const changeApproval = (connectorType: string, approved: boolean) => { - approval.reset() - if (!approved && (providers.get(connectorType)?.sourceCount ?? 0) > 0) { - setDeactivating(connectorType) + const visible = sources.flatMap((source) => { + const provider = providers.get(source.type) + return provider && + (provider.approved || provider.sourceCount > 0) && + source.meta.name.toLowerCase().includes(query) + ? [{ ...source, provider }] + : [] + }) + const ready = Boolean( + !overview.isPending && + !overview.isError && + availability.isIntegrationAvailabilityReady && + !availability.integrationAvailabilityError + ) + const closePicker = () => { + if (!approval.isPending) void setSetup({ addConnector: null, 'source-access': null }) + } + const selectSource = (type: string, accessMode: 'admin' | 'members') => { + const selectedType = searchSetupParam.parser.parse(type) + if (!selectedType || !ready || approval.isPending) return + const startSetup = () => + void setSetup({ + addConnector: selectedType, + 'source-access': accessMode === 'members' ? 'members' : null, + }) + if (providers.get(type)?.approved) { + startSetup() return } approval.mutate( - { organizationId: organization.id, connectorType, approved }, - { onError: (error) => toast.error(error.message) } + { organizationId: organization.id, connectorType: type, approved: true }, + { onSuccess: startSetup, onError: (error) => toast.error(error.message) } ) } if (!viewer.isAdmin) return null @@ -59,113 +98,94 @@ export function OrganizationIntegrationsSetup() { ) + const feedback = overview.isError ? ( + void overview.refetch()} + variant='inline' + /> + ) : availability.integrationAvailabilityError ? ( + void availability.refetchIntegrationAvailability()} + variant='inline' + /> + ) : null + return ( - - {availability.integrationAvailabilityError && ( - void availability.refetchIntegrationAvailability()} - variant='inline' - /> - )} -
- {overview.isError ? ( - void overview.refetch()} - variant='inline' - /> - ) : overview.isPending ? ( - Loading sources… - ) : visible.length === 0 ? ( - No matching sources - ) : ( - visible.map(([type, meta]) => { - const provider = providers.get(type) - const approved = provider?.approved === true - const sourceCount = provider?.sourceCount ?? 0 - const access = getConnectorAccessAvailability( - meta, - availability.integrationAvailability, - { - memberAccessAvailable: searchAccess.memberScoped, - mirroredAccessAvailable: searchAccess.sourceMirrored, - oauthServiceAvailability: availability.oauthServiceAvailability, - isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady, - } - ) - const available = access.admin || access.members - const hasSources = sourceCount > 0 - const manage = hasSources || canConnectWithDefaults(meta) - let description = provider ? organizationSearchStatusLabel(provider) : undefined - if (!hasSources && availability.isIntegrationAvailabilityReady && !available) - description = 'Unavailable in this deployment' - return ( - } - title={meta.name} - description={description} - trailing={ -
- {(hasSources || (approved && available)) && ( - - {manage ? 'Manage' : 'Set up'} - - )} - changeApproval(type, checked)} - /> -
- } - /> - ) - }) - )} -
- { - if (!open && !approval.isPending) setDeactivating(null) - }} - title={`Deactivate ${deactivatingName ?? 'integration'}?`} - text='Its content will be unavailable in Search, Assistant, and MCP. Sources and connected accounts are preserved.' - confirm={{ - label: 'Deactivate', - variant: 'destructive', - pending: approval.isPending, - onClick: () => { - if (!deactivating) return - approval.mutate( - { organizationId: organization.id, connectorType: deactivating, approved: false }, - { onSuccess: () => setDeactivating(null) } - ) + <> + void setSetup({ addConnector: '', 'source-access': null }), }, - }} + ]} + search={{ value: search, onChange: setSearch, placeholder: 'Search sources...' }} > - {approval.error?.message} - - + {setup.addConnector !== '' && feedback} +
+ {overview.isError ? null : overview.isPending ? ( + Loading sources… + ) : visible.length === 0 ? ( + + {query ? 'No matching sources' : 'No sources yet. Add a source to get started.'} + + ) : ( + visible.map(({ type, meta, access, provider }) => { + const available = access.admin || access.members + const status = + provider.approved && ready && !available + ? 'Unavailable in this deployment' + : organizationSearchStatusLabel(provider) + return ( + } + title={meta.name} + description={[ + status, + provider.sourceCount > 0 + ? `${provider.sourceCount} ${provider.sourceCount === 1 ? 'connection' : 'connections'}` + : undefined, + ] + .filter(Boolean) + .join(' · ')} + href={organizationRoutes(organization.id).searchProvider(type)} + clickLabel={`Manage ${meta.name}`} + navigable + /> + ) + }) + )} +
+
+ {setup.addConnector === '' ? ( + + ) : ( + + )} - + ) } diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period.test.tsx new file mode 100644 index 00000000000..c5b62c0626c --- /dev/null +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period.test.tsx @@ -0,0 +1,127 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OrganizationSearchStatsPeriod } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period' + +let root: Root +let container: HTMLDivElement +let originalScroll: typeof HTMLElement.prototype.scrollIntoView +const onChange = vi.fn() + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + originalScroll = HTMLElement.prototype.scrollIntoView + HTMLElement.prototype.scrollIntoView = vi.fn() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + HTMLElement.prototype.scrollIntoView = originalScroll + vi.unstubAllGlobals() +}) + +async function render() { + await act(async () => + root.render( + + ) + ) +} +async function selectPeriod(label: string) { + const trigger = container.querySelector('[role="combobox"]') + expect(trigger).not.toBeNull() + await act(async () => + trigger?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + ) + const option = Array.from(document.querySelectorAll('[role="option"]')).find( + (item) => item.textContent === label + ) + expect(option).toBeDefined() + await act(async () => option?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))) +} +async function click(label: string) { + const button = Array.from(document.querySelectorAll('button')).find( + (item) => item.textContent === label + ) + expect(button).toBeDefined() + await act(async () => button?.click()) +} + +describe('Stats date selection', () => { + it('keeps the existing range when calendar edits are cancelled', async () => { + await render() + await selectPeriod('Custom range') + expect( + document + .querySelector('[data-radix-popper-content-wrapper]') + ?.contains(document.activeElement) + ).toBe(true) + await click('5') + await click('8') + expect(onChange).not.toHaveBeenCalled() + await click('Cancel') + await vi.waitFor(() => + expect(document.activeElement).toBe(container.querySelector('[role="combobox"]')) + ) + expect(onChange).not.toHaveBeenCalled() + expect(document.body.textContent).not.toContain('Apply') + expect(container.textContent).toContain('Sep 1 – Sep 3') + }) + it('applies both date-only boundaries in one update and closes the calendar', async () => { + await render() + await selectPeriod('Custom range') + await click('5') + await click('8') + await click('Apply') + expect(onChange).toHaveBeenCalledExactlyOnceWith({ + period: 'custom', + startDate: '2024-09-05', + endDate: '2024-09-08', + }) + expect(document.body.textContent).not.toContain('Apply') + }) + it('clears custom boundaries when choosing a preset', async () => { + await render() + await selectPeriod('Past 3 days') + expect(onChange).toHaveBeenCalledExactlyOnceWith({ + period: '3d', + startDate: null, + endDate: null, + }) + }) + it('opens the calendar from the keyboard and restores focus on Escape', async () => { + await render() + const trigger = container.querySelector('[role="combobox"]') + await act(async () => { + trigger?.focus() + trigger?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + }) + for (let index = 0; index < 6; index++) { + await act(async () => + trigger?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + ) + } + await act(async () => + trigger?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + ) + expect(document.body.textContent).toContain('Apply') + expect(document.activeElement).not.toBe(trigger) + await act(async () => + document.activeElement?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ) + ) + await vi.waitFor(() => expect(document.activeElement).toBe(trigger)) + expect(onChange).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period.tsx new file mode 100644 index 00000000000..cff874b2785 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period.tsx @@ -0,0 +1,89 @@ +'use client' + +import { useRef, useState } from 'react' +import { Calendar, ChipCombobox, Popover, PopoverAnchor, PopoverContent, toast } from '@sim/emcn' +import { formatDateShort } from '@/lib/core/utils/date-display' +import { getSearchStatsRangeError, type SEARCH_STATS_PERIODS } from '@/lib/knowledge/search/stats' + +const PERIOD_OPTIONS = [ + { value: 'today', label: 'Today' }, + { value: '3d', label: 'Past 3 days' }, + { value: '7d', label: 'Past 7 days' }, + { value: '14d', label: 'Past 14 days' }, + { value: '30d', label: 'Past 30 days' }, + { value: '90d', label: 'Past 90 days' }, + { value: 'custom', label: 'Custom range' }, +] satisfies { value: (typeof SEARCH_STATS_PERIODS)[number]; label: string }[] + +interface SearchStatsPeriodSelection { + period: (typeof SEARCH_STATS_PERIODS)[number] + startDate: string | null + endDate: string | null +} + +interface OrganizationSearchStatsPeriodProps extends SearchStatsPeriodSelection { + onChange: (selection: SearchStatsPeriodSelection) => void +} + +export function OrganizationSearchStatsPeriod({ + period, + startDate, + endDate, + onChange, +}: OrganizationSearchStatsPeriodProps) { + const triggerContainerRef = useRef(null) + const calendarRef = useRef(null) + const [calendarOpen, setCalendarOpen] = useState(false) + const label = + period === 'custom' && startDate && endDate && !getSearchStatsRangeError({ startDate, endDate }) + ? `${formatDateShort(startDate)} – ${formatDateShort(endDate)}` + : PERIOD_OPTIONS.find((option) => option.value === period)?.label + + return ( +
+ { + const selected = PERIOD_OPTIONS.find((option) => option.value === value) + if (!selected) return + if (selected.value === 'custom') setCalendarOpen(true) + else onChange({ period: selected.value, startDate: null, endDate: null }) + }} + /> + + + calendarRef.current?.focus()} + onCloseAutoFocus={() => + triggerContainerRef.current?.querySelector('[role="combobox"]')?.focus() + } + > + setCalendarOpen(false)} + onRangeChange={(start, end) => { + const error = getSearchStatsRangeError({ startDate: start, endDate: end }) + if (error) { + toast.error(error) + return + } + onChange({ period: 'custom', startDate: start, endDate: end }) + setCalendarOpen(false) + }} + /> + + +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.test.ts b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.test.ts index dc1bfa33cfa..90b069f5f44 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.test.ts +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.test.ts @@ -16,13 +16,13 @@ describe('organization source status labels', () => { it('describes the next step instead of calling all empty integrations unconfigured', () => { expect(organizationSearchStatusLabel(provider)).toBe('Waiting for connections') expect(organizationSearchStatusLabel({ ...provider, status: 'needs_setup' })).toBe( - 'Source not configured' + 'Setup required' ) expect( organizationSearchStatusLabel({ ...provider, status: 'needs_setup', sourceCount: 1 }) ).toBe('Waiting for first sync') expect(organizationSearchStatusLabel({ ...provider, status: 'active', sourceCount: 1 })).toBe( - 'Enabled' + 'Ready to search' ) }) it.each([ @@ -50,6 +50,6 @@ describe('organization source status labels', () => { status: 'needs_attention', issue: 'sync_failed', }) - ).toBe('Disabled') + ).toBe('Deactivated') }) }) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts index 547f0339812..5988b08ce56 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts @@ -1,16 +1,16 @@ import type { OrganizationSearchProviderSummary } from '@/lib/api/contracts/knowledge/connectors' const STATUS_LABELS: Record = { - needs_setup: 'Source not configured', + needs_setup: 'Setup required', waiting_for_connections: 'Waiting for connections', indexing: 'Indexing', needs_attention: 'Sync failed', paused: 'Paused', - active: 'Enabled', + active: 'Ready to search', } export function organizationSearchStatusLabel(provider: OrganizationSearchProviderSummary): string { - if (!provider.approved) return 'Disabled' + if (!provider.approved) return 'Deactivated' if (provider.status === 'needs_setup' && provider.sourceCount > 0) return 'Waiting for first sync' if (provider.status === 'needs_attention') { const error = diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-people.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-people.tsx new file mode 100644 index 00000000000..338ba91c5d9 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-people.tsx @@ -0,0 +1,79 @@ +'use client' + +import type { ComponentProps, ReactNode } from 'react' +import { ChipSelect } from '@sim/emcn' +import { useQueryState } from 'nuqs' +import type { CredentialGroupOption } from '@/lib/api/contracts/credential-groups' +import { getCredentialGroupIndexingConnector } from '@/lib/credential-groups/indexing' +import { organizationPeopleIntegrationParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params' +import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people' + +interface OrganizationSourcePeopleProps + extends Omit, 'searchConnection' | 'filters'> { + options: CredentialGroupOption[] + tabs: ReactNode +} + +export function OrganizationSourcePeople({ + options, + tabs, + ...props +}: OrganizationSourcePeopleProps) { + const [integration, setIntegration] = useQueryState( + organizationPeopleIntegrationParam.key, + organizationPeopleIntegrationParam.parser + ) + const integrations = options + .flatMap((option) => { + const connector = getCredentialGroupIndexingConnector(option.provider) + return option.status === 'active' && connector + ? [ + { + optionId: option.id, + type: connector.type, + name: connector.meta.name, + icon: connector.meta.icon, + needsSetup: option.provider === 'slack' && option.configurationStatus !== 'ready', + }, + ] + : [] + }) + .sort((a, b) => a.name.localeCompare(b.name)) + const selected = integrations.find((item) => item.type === integration) + + return ( + +
+ {tabs} + void setIntegration(value === 'all' ? null : value)} + disabled={props.enabled === false || Boolean(props.setupFallback)} + options={[ + { value: 'all', label: 'All integrations' }, + ...integrations.map((item) => ({ + value: item.type, + label: item.name, + icon: item.icon, + })), + ]} + /> +
+ {selected?.needsSetup && ( +

+ Update the Slack app from Sources before requesting connections. +

+ )} + + } + /> + ) +} diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.test.tsx new file mode 100644 index 00000000000..11481d6a1f4 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.test.tsx @@ -0,0 +1,152 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrganizationSearchStats } from '@/lib/api/contracts/knowledge/search-stats' + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + chart: vi.fn(), + refetch: vi.fn(), + updateUrl: vi.fn(), +})) +vi.mock('@/hooks/queries/organization-search-stats', () => ({ + useOrganizationSearchStats: mocks.query, +})) +vi.mock('@/components/charts', () => ({ + BarChart: (props: unknown) => { + mocks.chart(props) + return
Daily chart
+ }, +})) +vi.mock('@/connectors/registry', () => ({ + CONNECTOR_META_REGISTRY: { confluence: { name: 'Confluence' }, jira: { name: 'Jira' } }, +})) +vi.mock('next/navigation', () => ({ useRouter: () => ({}) })) + +import { SettingsHeaderProvider } from '@/components/settings/settings-header' +import { OrganizationSourceStats } from '@/app/o/[organizationId]/settings/components/integrations/organization-source-stats' + +const data: OrganizationSearchStats = { + start: '2026-09-04T00:00:00.000Z', + end: '2026-09-10T20:00:00.000Z', + totals: { invocations: 5, activePeople: 2, results: 8 }, + series: [{ timestamp: '2026-09-10T00:00:00.000Z', invocations: 5 }], + sources: [{ sourceType: 'confluence', invocations: 4 }], + surfaces: [ + { surface: 'dashboard', invocations: 3 }, + { surface: 'mcp', invocations: 2 }, + ], + people: [ + { + userId: 'person', + name: 'Alex', + email: 'alex@example.com', + invocations: 5, + sourceTypes: ['confluence', 'jira'], + }, + ], +} +let root: Root +let container: HTMLDivElement + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.query.mockReturnValue({ data, isError: false, refetch: mocks.refetch }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) +async function render(searchParams = '') { + await act(async () => + root.render( + + + Sources People Stats} /> + + + ) + ) +} + +describe('organization Search stats', () => { + it('renders request metrics, source labels, people and the canonical daily chart', async () => { + await render() + expect(container.textContent).not.toContain('No-result rate') + expect(container.textContent).toContain('Confluence, Jira') + expect(container.textContent).toContain('alex@example.com') + expect(mocks.chart).toHaveBeenCalledWith( + expect.objectContaining({ data: [{ timestamp: data.series[0].timestamp, value: 5 }] }) + ) + expect(container.textContent).not.toMatch(/citations|clicks|agent runs/i) + }) + it('restores filters from the URL and scopes every metric request consistently', async () => { + await render('?stats-period=7d&stats-surface=mcp') + expect(mocks.query).toHaveBeenLastCalledWith({ + organizationId: 'org', + period: '7d', + surface: 'mcp', + }) + expect(container.textContent).not.toContain('Invocations by surface') + const filter = container.querySelector('button[aria-label="Search surface"]') + const toolbar = filter?.parentElement?.parentElement + expect(toolbar?.textContent).toContain('Sources People Stats') + }) + it('restores a custom UTC range with the surface filter', async () => { + await render( + '?stats-period=custom&stats-start=2026-09-01&stats-end=2026-09-03&stats-surface=slack' + ) + expect(mocks.query).toHaveBeenLastCalledWith({ + organizationId: 'org', + period: 'custom', + startDate: '2026-09-01', + endDate: '2026-09-03', + surface: 'slack', + }) + expect(container.textContent).toContain('Sep 1 – Sep 3') + }) + it('keeps the daily chart mounted with zero-valued buckets when there is no activity', async () => { + mocks.query.mockReturnValue({ + data: { + ...data, + totals: { invocations: 0, activePeople: 0, results: 0 }, + series: data.series.map((point) => ({ ...point, invocations: 0 })), + sources: [], + surfaces: [], + people: [], + }, + isError: false, + }) + await render() + expect(container.textContent).not.toContain('No recorded Search activity') + expect(container.querySelectorAll('dd')).toHaveLength(3) + expect(Array.from(container.querySelectorAll('dd'), (metric) => metric.textContent)).toEqual([ + '0', + '0', + '0', + ]) + expect(mocks.chart).toHaveBeenCalledWith( + expect.objectContaining({ + data: [{ timestamp: data.series[0].timestamp, value: 0 }], + }) + ) + }) + it('offers retry instead of displaying stale successful totals on failure', async () => { + mocks.query.mockReturnValue({ data, isError: true, refetch: mocks.refetch }) + await render() + expect(container.textContent).toContain('Couldn’t load Search stats') + expect(container.textContent).not.toContain('Search invocations') + const retry = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Try again' + ) + await act(async () => retry?.click()) + expect(mocks.refetch).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.tsx new file mode 100644 index 00000000000..1765187b12d --- /dev/null +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-source-stats.tsx @@ -0,0 +1,227 @@ +'use client' + +import { type ReactNode, useMemo } from 'react' +import { Chip, ChipSelect, Tooltip } from '@sim/emcn' +import { CircleInfo } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' +import { BarChart } from '@/components/charts' +import { + SEARCH_STATS_PEOPLE_LIMIT, + SEARCH_STATS_SURFACE_LABELS, + SEARCH_STATS_SURFACES, +} from '@/lib/knowledge/search/stats' +import { OrganizationSearchStatsPeriod } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-stats-period' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import { + organizationSearchStatsParsers, + organizationSearchStatsUrlOptions, +} from '@/ee/organization-search-stats/search-params' +import { useOrganizationSearchStats } from '@/hooks/queries/organization-search-stats' + +const SURFACE_OPTIONS = [ + { value: 'all', label: 'All surfaces' }, + ...SEARCH_STATS_SURFACES.map((surface) => ({ + value: surface, + label: SEARCH_STATS_SURFACE_LABELS[surface], + })), +] + +interface OrganizationSourceStatsProps { + organizationId: string + tabs?: ReactNode +} + +function sourceLabel(sourceType: string) { + return ( + CONNECTOR_META_REGISTRY[sourceType]?.name ?? (sourceType === 'uploads' ? 'Uploads' : sourceType) + ) +} + +export function OrganizationSourceStats({ organizationId, tabs }: OrganizationSourceStatsProps) { + const [{ period, surface, startDate, endDate }, setFilters] = useQueryStates( + organizationSearchStatsParsers, + organizationSearchStatsUrlOptions + ) + const stats = useOrganizationSearchStats({ + organizationId, + period, + surface: surface ?? undefined, + ...(period === 'custom' + ? { startDate: startDate ?? undefined, endDate: endDate ?? undefined } + : {}), + }) + const series = useMemo( + () => + stats.data?.series.map((point) => ({ + timestamp: point.timestamp, + value: point.invocations, + })) ?? [], + [stats.data?.series] + ) + const data = stats.data + const totals = data?.totals + const metrics = totals + ? [ + { label: 'Search invocations', value: totals.invocations.toLocaleString() }, + { label: 'Active people', value: totals.activePeople.toLocaleString() }, + { label: 'Results returned', value: totals.results.toLocaleString() }, + ] + : [] + + return ( + +
+ {tabs} +
+ + void setFilters({ surface: organizationSearchStatsParsers.surface.parse(value) }) + } + /> + void setFilters(selection)} + /> +
+
+ {stats.isError ? ( + + Couldn’t load Search stats. void stats.refetch()}>Try again + + ) : !data || !totals ? ( + Loading Search stats… + ) : ( + <> +
+ {metrics.map((metric) => ( +
+
{metric.label}
+
{metric.value}
+
+ ))} +
+ + + + + + Successful Search requests since tracking was enabled. Assistant and MCP counts + are Search tool calls. Results count each document once per request. One request + can return multiple sources. + + + } + action={ + + {period === 'custom' ? 'UTC' : 'UTC · Includes today'} + + } + > + + + {!surface && totals.invocations > 0 && ( + +
+ {data.surfaces.map((row) => ( + + {row.invocations.toLocaleString()} + + } + /> + ))} +
+
+ )} + + Invocations returning this source + + } + > + {data.sources.length ? ( +
+ {data.sources.map((row) => { + const Icon = CONNECTOR_META_REGISTRY[row.sourceType]?.icon + return ( + : undefined} + title={sourceLabel(row.sourceType)} + badge={ + + {row.invocations.toLocaleString()} + + } + /> + ) + })} +
+ ) : ( + + No sources returned in this period. + + )} +
+ + Top {SEARCH_STATS_PEOPLE_LIMIT} · Invocations + + } + > + {data.people.length ? ( +
+ {data.people.map((person) => ( + + {person.invocations.toLocaleString()} + + } + /> + ))} +
+ ) : ( + + No active people in this period. + + )} +
+ + )} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/search-params.ts b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-params.ts index 30d5f4c5561..ad97007ca37 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/search-params.ts +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-params.ts @@ -1,8 +1,8 @@ -import { parseAsStringLiteral } from 'nuqs/server' +import { createSerializer, parseAsString, parseAsStringLiteral } from 'nuqs/server' export const organizationIntegrationsTabParam = { key: 'tab', - parser: parseAsStringLiteral(['providers', 'people']).withDefault('providers'), + parser: parseAsStringLiteral(['providers', 'people', 'stats']).withDefault('providers'), } as const export const connectedAccountsParam = { @@ -10,7 +10,14 @@ export const connectedAccountsParam = { parser: parseAsStringLiteral(['slack']), } as const -export const organizationProviderTabParam = { - key: 'view', - parser: parseAsStringLiteral(['sources', 'accounts']).withDefault('sources'), +/** An absent integration includes connections from all integrations. */ +export const organizationPeopleIntegrationParam = { + key: 'integration', + parser: parseAsString, } as const + +export const serializeOrganizationPeople = createSerializer({ + tab: organizationIntegrationsTabParam.parser, + integration: organizationPeopleIntegrationParam.parser, + 'credential-group-people': parseAsString, +}) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-removal.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-removal.tsx index 2dff4d17b5f..10dac1eb1bb 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-removal.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-removal.tsx @@ -24,8 +24,8 @@ export function OrganizationSlackAccountRemoval({ onOpenChange={(open) => { if (!open && !update.isPending) onClose() }} - title='Remove Slack account setup?' - text='This disconnects your organization’s Slack accounts and clears their saved app configuration. Remove any sources using these accounts first.' + title='Remove Slack app setup?' + text='This disconnects your organization’s Slack accounts and clears their saved app configuration. Remove any connections using these accounts first.' confirm={{ label: 'Remove', variant: 'destructive', diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.test.tsx index 1cc0c9b77b9..eef37d52a2f 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.test.tsx @@ -72,8 +72,57 @@ describe('organization Slack setup continuation', () => { it('prepares a missing container without asking the admin to make an extra choice', async () => { await render() expect(mocks.ensure).toHaveBeenCalledExactlyOnceWith({ organizationId: 'org-a' }) - expect(document.body.textContent).toContain('Loading Slack setup') - expect(document.body.textContent).not.toContain('Continue') + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(mocks.modal).not.toHaveBeenCalled() + mocks.prepare.mockReturnValue({ mutate: mocks.ensure, isIdle: false, isPending: true }) + await render() + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(mocks.ensure).toHaveBeenCalledOnce() + mocks.prepare.mockReturnValue({ + mutate: mocks.ensure, + isIdle: false, + isPending: false, + data: { credentialGroup: { id: 'group-a', organizationId: 'org-a', options: [] } }, + }) + await render() + expect(mocks.modal).toHaveBeenCalledWith( + expect.objectContaining({ credentialGroupId: 'group-a' }), + undefined + ) + }) + + it('does not open a temporary modal while discovering accounts', async () => { + mocks.accounts.mockReturnValue({ isSuccess: false, isPending: true }) + await render() + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(mocks.ensure).not.toHaveBeenCalled() + expect(mocks.modal).not.toHaveBeenCalled() + }) + + it.each(['discovery', 'preparation'])('keeps %s errors recoverable', async (stage) => { + const retry = vi.fn() + if (stage === 'discovery') { + mocks.accounts.mockReturnValue({ + isSuccess: false, + error: new Error('Could not load Slack setup'), + refetch: retry, + }) + } else { + mocks.prepare.mockReturnValue({ + mutate: mocks.ensure, + isIdle: false, + error: new Error('Could not prepare Slack setup'), + }) + } + await render() + expect(document.body.textContent).toContain('Set up Slack app') + const retryButton = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent === 'Try again' + ) + expect(retryButton).toBeDefined() + await act(async () => retryButton?.click()) + if (stage === 'discovery') expect(retry).toHaveBeenCalledOnce() + else expect(mocks.ensure).toHaveBeenCalledExactlyOnceWith({ organizationId: 'org-a' }) }) it('does not prepare accounts or open admin setup for an ordinary member', async () => { diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx index e5e46838ec2..37d11404811 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/slack-account-setup.tsx @@ -15,10 +15,7 @@ import { searchSetupParam, searchSetupReturnParam, } from '@/app/workspace/[workspaceId]/search/search-params' -import { - SettingsEmptyState, - SettingsQueryErrorState, -} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal' import { useEnsureOrganizationAccounts, @@ -83,32 +80,30 @@ export function OrganizationSlackAccountSetup() { }} /> ) + const error = accounts.error ?? setupError + if (!error) return null return ( { if (!next) close() }} - srTitle='Connect Slack accounts' + srTitle='Set up Slack app' > - Connect Slack accounts + Set up Slack app - - {accounts.error || setupError ? ( - - accounts.error - ? void accounts.refetch() - : ensureAccounts({ organizationId: organization.id }) - } - variant='inline' - /> - ) : ( - Loading Slack setup… - )} + + + accounts.error + ? void accounts.refetch() + : ensureAccounts({ organizationId: organization.id }) + } + variant='inline' + /> diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.test.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.test.tsx index 25ff75fb3e8..bf09e5c23f9 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.test.tsx @@ -1,105 +1,81 @@ /** @vitest-environment node */ -import { Suspense } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ - session: vi.fn(), - authorize: vi.fn(), - redirect: vi.fn(), - notFound: vi.fn(), -})) +const mocks = vi.hoisted(() => ({ session: vi.fn(), authorize: vi.fn(), redirect: vi.fn() })) vi.mock('@/lib/auth', () => ({ getSession: mocks.session })) vi.mock('@/lib/settings/application/organization-section-access', () => ({ authorizeOrganizationSettingsSection: mocks.authorize, })) -vi.mock('@/lib/sim-search/connectors', () => ({ - SEARCH_SOURCE_TYPES: [['google_drive', { name: 'Google Drive' }]], +vi.mock('next/navigation', () => ({ + redirect: (url: string) => { + mocks.redirect(url) + throw new Error('redirect') + }, + notFound: () => { + throw new Error('not found') + }, })) -vi.mock('next/navigation', () => ({ redirect: mocks.redirect, notFound: mocks.notFound })) -vi.mock('@/components/settings/settings-header', () => ({ - SettingsHeaderProvider: 'header-provider', - SettingsHeaderShell: 'header-shell', +vi.mock('@/lib/sim-search/connectors', () => ({ + SEARCH_SOURCE_TYPES: [ + ['jira', { name: 'Jira' }], + ['confluence', { name: 'Confluence' }], + ], })) vi.mock( '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail', - () => ({ OrganizationProviderDetail: 'provider-detail' }) + () => ({ OrganizationProviderDetail: () => null }) ) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ + SettingsEmptyState: () => null, +})) -import OrganizationProviderLayout from '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/layout' -import OrganizationProviderPage, { - generateMetadata, -} from '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page' - -const props = { - params: Promise.resolve({ organizationId: 'org-one', connectorType: 'google_drive' }), -} - -describe('organization provider page authorization', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.session.mockResolvedValue({ user: { id: 'admin-one' } }) - mocks.authorize.mockResolvedValue(true) - mocks.redirect.mockImplementation(() => { - throw new Error('redirect') - }) - mocks.notFound.mockImplementation(() => { - throw new Error('not found') - }) - }) +import OrganizationProviderPage from '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page' - it('checks the target organization integration policy before rendering provider management', async () => { - const page = await OrganizationProviderPage(props) - expect(mocks.authorize).toHaveBeenCalledWith({ - organizationId: 'org-one', - userId: 'admin-one', - section: 'integrations', - }) - expect(page.type).toBe(Suspense) - expect(page.props.children).toMatchObject({ - type: 'provider-detail', - props: { connectorType: 'google_drive' }, - }) - }) +beforeEach(() => { + vi.clearAllMocks() + mocks.session.mockResolvedValue({ user: { id: 'admin-1' } }) + mocks.authorize.mockResolvedValue(true) +}) - it('rejects unknown or unsupported providers before loading protected organization state', async () => { +it.each(['jira', 'confluence'])( + 'moves legacy %s Accounts links to filtered People and preserves the search', + async (connectorType) => { await expect( OrganizationProviderPage({ - params: Promise.resolve({ organizationId: 'org-one', connectorType: 'unknown-provider' }), + params: Promise.resolve({ organizationId: 'org-1', connectorType }), + searchParams: Promise.resolve({ + view: 'accounts', + 'credential-group-people': 'alex+qa@example.com', + }), }) - ).rejects.toThrow('not found') - expect(mocks.session).not.toHaveBeenCalled() - expect(mocks.authorize).not.toHaveBeenCalled() - }) - - it('does not render when the admin or feature gate denies access', async () => { - mocks.authorize.mockResolvedValue(false) - await expect(OrganizationProviderPage(props)).rejects.toThrow('not found') - }) - - it('returns signed-out users to the requested provider after login', async () => { - mocks.session.mockResolvedValue(null) - await expect(OrganizationProviderPage(props)).rejects.toThrow('redirect') - const url = new URL(mocks.redirect.mock.calls[0][0], 'https://example.com') - expect(url.pathname).toBe('/login') - expect(url.searchParams.get('callbackUrl')).toBe( - '/o/org-one/settings/integrations/providers/google_drive' - ) - expect(mocks.authorize).not.toHaveBeenCalled() - }) + ).rejects.toThrow('redirect') + const url = new URL(mocks.redirect.mock.lastCall![0], 'https://example.com') + expect(url.pathname).toBe('/o/org-1/settings/integrations') + expect(url.searchParams.get('tab')).toBe('people') + expect(url.searchParams.get('integration')).toBe(connectorType) + expect(url.searchParams.get('credential-group-people')).toBe('alex+qa@example.com') + expect(url.searchParams.has('view')).toBe(false) + } +) - it('keeps the settings header mounted independently of provider authorization', () => { - const layout = OrganizationProviderLayout({ children: Loading integration }) - expect(layout.type).toBe('header-provider') - expect(layout.props.children).toMatchObject({ - type: 'header-shell', - props: { meta: { title: 'Integration' } }, +it('authorizes organization settings before redirecting a legacy link', async () => { + mocks.authorize.mockResolvedValue(false) + await expect( + OrganizationProviderPage({ + params: Promise.resolve({ organizationId: 'org-1', connectorType: 'jira' }), + searchParams: Promise.resolve({ view: 'accounts' }), }) - expect(mocks.authorize).not.toHaveBeenCalled() - }) - - it('uses the supported provider name as page metadata', async () => { - expect(await generateMetadata(props)).toEqual({ title: 'Google Drive' }) - expect(mocks.session).not.toHaveBeenCalled() - expect(mocks.authorize).not.toHaveBeenCalled() - }) + ).rejects.toThrow('not found') + expect(mocks.redirect).not.toHaveBeenCalled() }) + +it.each(['jira', ''])( + 'preserves an active setup in a legacy Accounts link (%s)', + async (addConnector) => { + await OrganizationProviderPage({ + params: Promise.resolve({ organizationId: 'org-1', connectorType: 'jira' }), + searchParams: Promise.resolve({ view: 'accounts', addConnector, 'source-access': 'members' }), + }) + expect(mocks.redirect).not.toHaveBeenCalled() + } +) diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.tsx index 8250da53e98..5f0762a0e49 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/page.tsx @@ -6,11 +6,14 @@ import { organizationRoutes } from '@/lib/navigation/paths' import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access' import { SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' +import { serializeOrganizationPeople } from '@/app/o/[organizationId]/settings/components/integrations/search-params' import { OrganizationProviderDetail } from '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail' +import { searchSetupParam } from '@/app/workspace/[workspaceId]/search/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' interface OrganizationProviderPageProps { params: Promise<{ organizationId: string; connectorType: string }> + searchParams: Promise> } export async function generateMetadata({ @@ -22,7 +25,10 @@ export async function generateMetadata({ } } -export default async function OrganizationProviderPage({ params }: OrganizationProviderPageProps) { +export default async function OrganizationProviderPage({ + params, + searchParams, +}: OrganizationProviderPageProps) { const { organizationId, connectorType } = await params if (!SEARCH_SOURCE_TYPES.some(([type]) => type === connectorType)) notFound() const session = await getSession() @@ -41,6 +47,26 @@ export default async function OrganizationProviderPage({ params }: OrganizationP })) ) notFound() + const query = await searchParams + const activeSetup = + typeof query.addConnector === 'string' + ? searchSetupParam.parser.parse(query.addConnector) + : null + if (query.view === 'accounts' && activeSetup === null) { + redirect( + serializeOrganizationPeople( + organizationRoutes(organizationId).settingsSection('integrations'), + { + tab: 'people', + integration: connectorType, + 'credential-group-people': + typeof query['credential-group-people'] === 'string' + ? query['credential-group-people'] + : null, + } + ) + ) + } return ( Loading integration…} diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx index 7dda89df3ff..594914fb52f 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx @@ -116,6 +116,7 @@ vi.mock('@/app/o/[organizationId]/settings/components/integrations/slack-account import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' import { OrganizationProviderDetail } from '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' const provider = { connectorType: 'google_drive', @@ -221,46 +222,21 @@ describe('organization provider management', () => { } it.each(['gmail', 'google_calendar', 'google_drive'])( - 'lets %s wait for connections without requiring source setup', + 'offers configuration setup directly for %s without an Accounts or Advanced tab', async (connectorType) => { mocks.overview.mockReturnValue({ - data: { - providers: [ - { - connectorType, - approved: true, - status: 'waiting_for_connections', - sourceCount: 0, - issue: null, - isSyncing: false, - }, - ], - }, + data: { providers: [{ ...provider, connectorType, status: 'waiting_for_connections' }] }, }) - mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, isPending: false }) mocks.sources.mockReturnValue({ data: [], isPending: false }) - await render(connectorType, '?view=accounts') + await render(connectorType) expect(container.textContent).toContain('Waiting for connections') expect(container.textContent).toContain( - 'Members connect their accounts from Integrations. Indexing starts automatically.' + `No ${CONNECTOR_META_REGISTRY[connectorType]?.name} connections yet.` ) - expect(container.textContent).not.toContain('Add source') - expect(container.textContent).not.toContain('Add sync configuration') - expect(container.querySelector('a[href="/o/org-one/integrations"]')).toBeNull() - expect(container.textContent).not.toContain('Open Integrations') - expect(mocks.sources).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ enabled: false }) - ) - await click('Advanced') - expect(container.textContent).toContain('No sync configurations yet.') - expect(container.textContent).toContain('Add sync configuration') - await click('Add sync configuration') + expect(container.querySelector('[role="radio"]')).toBeNull() + await click('Connect service account') await vi.waitFor(() => { - expect(mocks.updateUrl).toHaveBeenLastCalledWith( - expect.objectContaining({ searchParams: expect.any(URLSearchParams) }) - ) - expect(mocks.updateUrl.mock.calls.at(-1)![0].searchParams.get('addConnector')).toBe( + expect(mocks.updateUrl.mock.calls.at(-1)?.[0].searchParams.get('addConnector')).toBe( connectorType ) }) @@ -284,7 +260,7 @@ describe('organization provider management', () => { expect(container.textContent).not.toContain( 'Members connect their accounts from Integrations.' ) - await click('Add sync configuration') + await click('Connect service account') await vi.waitFor(() => { const params = mocks.updateUrl.mock.calls.at(-1)![0].searchParams expect(params.get('addConnector')).toBe(connectorType) @@ -320,29 +296,17 @@ describe('organization provider management', () => { ])('opens configurations for the $name setup', async ({ modes }) => { withSources(modes) await render(connectorType) - expect(container.querySelector('[role="radio"][aria-checked="true"]')).toHaveTextContent( - 'Advanced' - ) + expect(container.querySelector('[role="radio"]')).toBeNull() expect(mocks.sources).toHaveBeenLastCalledWith( expect.any(Object), expect.objectContaining({ enabled: true }) ) expect(mocks.accounts).toHaveBeenLastCalledWith(undefined) - expect(container.textContent).toContain('Add sync configuration') + expect(container.textContent).toContain('Connect service account') if (modes.length === 0) - expect(container.textContent).toContain('No sync configurations yet.') - }) - - it.each(['accounts', 'sources'])('honors explicit %s links', async (view) => { - withSources(['admin']) - await render(connectorType, `?view=${view}`) - expect(mocks.sources).toHaveBeenLastCalledWith( - expect.any(Object), - expect.objectContaining({ enabled: view === 'sources' }) - ) - expect(container.querySelector('[role="radio"][aria-checked="true"]')).toHaveTextContent( - view === 'accounts' ? 'Accounts' : 'Advanced' - ) + expect(container.textContent).toContain( + `No ${CONNECTOR_META_REGISTRY[connectorType]?.name} connections yet.` + ) }) it('loads the configuration list in parallel with its overview and retains the default', async () => { @@ -357,9 +321,7 @@ describe('organization provider management', () => { ) withSources(['members']) await render(connectorType) - expect(container.querySelector('[role="radio"][aria-checked="true"]')).toHaveTextContent( - 'Advanced' - ) + expect(container.querySelector('[role="radio"]')).toBeNull() expect(container.textContent).toContain('Engineering handbook') expect(mocks.sources).toHaveBeenLastCalledWith( expect.any(Object), @@ -367,43 +329,6 @@ describe('organization provider management', () => { ) expect(mocks.accounts).toHaveBeenLastCalledWith(undefined) }) - - it('preserves an explicit Accounts choice when the overview changes', async () => { - withSources(['members']) - await render(connectorType) - await click('Accounts') - await vi.waitFor(() => - expect(mocks.updateUrl.mock.calls.at(-1)?.[0].searchParams.get('view')).toBe('accounts') - ) - withSources(['admin']) - await render(connectorType) - expect(container.querySelector('[role="radio"][aria-checked="true"]')).toHaveTextContent( - 'Accounts' - ) - expect(mocks.sources).toHaveBeenLastCalledWith( - expect.any(Object), - expect.objectContaining({ enabled: false }) - ) - }) - - it.each([ - { accessMode: 'admin', method: 'Service account' }, - { accessMode: 'members', method: 'Member accounts' }, - ])( - 'identifies $method configurations without changing their title or destination', - async ({ accessMode, method }) => { - withSources([accessMode]) - mocks.sources.mockReturnValue({ - data: [{ ...source, connectorType, accessMode }], - isPending: false, - }) - await render(connectorType) - expect(container.textContent).toContain(`${method} · Last synced`) - expect( - container.querySelector('a[aria-label="Open Engineering handbook"]') - ).toHaveAttribute('href', '/o/org-one/settings/integrations/sources/source-one') - } - ) } ) @@ -412,7 +337,15 @@ describe('organization provider management', () => { async (status) => { withSlackAccounts(true, status) await render('slack') - await click('Remove account setup') + const headerRemove = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Remove app setup' + ) + const deactivate = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Deactivate' + ) + expect(headerRemove?.className).toBe(deactivate?.className) + expect(headerRemove?.querySelector('[class*="text-error"]')).toBeNull() + await click('Remove app setup') expect(mocks.removeAccounts).not.toHaveBeenCalled() expect(document.querySelector('[role="dialog"]')).toHaveTextContent('saved app configuration') await click('Remove') @@ -434,7 +367,7 @@ describe('organization provider management', () => { it('offers removal when Slack is deactivated and allows cancelling without a mutation', async () => { withSlackAccounts(false) await render('slack') - await click('Remove account setup') + await click('Remove app setup') await click('Cancel') expect(document.querySelector('[role="dialog"]')).toBeNull() expect(mocks.removeAccounts).not.toHaveBeenCalled() @@ -444,32 +377,19 @@ describe('organization provider management', () => { withSlackAccounts() mocks.accountRemovalError = new Error('Remove the source using these accounts first.') await render('slack') - await click('Remove account setup') + await click('Remove app setup') await click('Remove') expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent( 'Remove the source using these accounts first.' ) }) - it('passes the removal action to the Slack Accounts tab header', async () => { - withSlackAccounts() - await render('slack', '?view=accounts') - const actions = mocks.people.mock.calls.at(-1)![0].panel.actions - expect(actions).toEqual([ - expect.objectContaining({ text: 'Remove account setup', onSelect: expect.any(Function) }), - ]) - await act(async () => actions[0].onSelect()) - expect(document.querySelector('[role="dialog"]')).toHaveTextContent( - 'Remove Slack account setup?' - ) - }) - it('keeps Slack cleanup available even when personal source creation is unavailable', async () => { withSlackAccounts() mocks.personal = false await render('slack') expect(mocks.accounts).toHaveBeenCalledWith('org-one') - await click('Remove account setup') + await click('Remove app setup') expect(document.querySelector('[role="dialog"]')).not.toBeNull() }) @@ -538,14 +458,12 @@ describe('organization provider management', () => { expect(container.textContent).not.toContain('Activate this integration') expect(mocks.people).not.toHaveBeenCalled() expect( - container.querySelector( - `input[placeholder="${params === '?view=accounts' ? 'Search people...' : 'Search sync configurations...'}"]` - ) + container.querySelector('input[placeholder="Search Google Drive connections..."]') ).toBeEnabled() } ) - it('hides cached account content and retries when overview access is revoked', async () => { + it('hides cached source content and retries when overview access is revoked', async () => { const refetch = vi.fn() mocks.overview.mockReturnValue({ data: { providers: [provider] }, @@ -553,21 +471,29 @@ describe('organization provider management', () => { error: new ApiClientError({ status: 403, message: 'Access denied', body: null }), refetch, }) - await render('google_drive', '?view=accounts&credential-group-people=alex') + await render('google_drive', '?search=alex') expect(container.textContent).toContain('Access denied') expect(mocks.people).not.toHaveBeenCalled() - expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') - expect(container.querySelector('input[placeholder="Search people..."]')).toBeEnabled() + expect( + container.querySelector('input[placeholder="Search Google Drive connections..."]') + ).toHaveValue('alex') + expect( + container.querySelector('input[placeholder="Search Google Drive connections..."]') + ).toBeEnabled() await click('Try again') expect(refetch).toHaveBeenCalledOnce() }) - it('offers activation from the accounts tab when an existing provider is deactivated', async () => { + it('offers activation when an existing provider is deactivated', async () => { mocks.overview.mockReturnValue({ data: { providers: [{ ...provider, approved: false }] } }) - await render('google_drive', '?view=accounts&credential-group-people=alex') + await render('google_drive', '?search=alex') expect(mocks.people).not.toHaveBeenCalled() - expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') - expect(container.querySelector('input[placeholder="Search people..."]')).toBeEnabled() + expect( + container.querySelector('input[placeholder="Search Google Drive connections..."]') + ).toHaveValue('alex') + expect( + container.querySelector('input[placeholder="Search Google Drive connections..."]') + ).toBeEnabled() await click('Activate') expect(mocks.activate).toHaveBeenCalledWith({ organizationId: 'org-one', @@ -576,7 +502,7 @@ describe('organization provider management', () => { }) }) - it('shows activation failures on Accounts and keeps activation available for retry', async () => { + it('shows activation failures and keeps activation available for retry', async () => { mocks.overview.mockReturnValue({ data: { providers: [{ ...provider, approved: false }] } }) mocks.approvalError = new Error('Activation could not be saved') await render('google_drive', '?view=accounts') @@ -590,56 +516,6 @@ describe('organization provider management', () => { }) }) - it.each([ - ['loading', 'Loading accounts…'], - ['error', 'Accounts unavailable'], - [ - 'missing group', - 'Members connect their accounts from Integrations. Indexing starts automatically.', - ], - [ - 'missing provider option', - 'Members connect their accounts from Integrations. Indexing starts automatically.', - ], - ])('preserves Accounts search while %s', async (state, message) => { - const refetch = vi.fn() - mocks.accounts.mockReturnValue( - state === 'loading' - ? { isPending: true } - : state === 'error' - ? { isError: true, error: new Error(message), refetch } - : { - data: { - credentialGroup: - state === 'missing group' ? null : { ...credentialGroup, options: [] }, - }, - isPending: false, - } - ) - await render('google_drive', '?view=accounts&credential-group-people=alex&search=handbook') - - expect(container.textContent).toContain(message) - expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') - expect(container.querySelector('input[placeholder="Search people..."]')).toBeEnabled() - expect(container.querySelector('input[placeholder="Search sync configurations..."]')).toBeNull() - expect(mocks.people).not.toHaveBeenCalled() - if (state === 'error') { - await click('Try again') - expect(refetch).toHaveBeenCalledOnce() - } - - const sourcesTab = Array.from( - container.querySelectorAll('[role="radio"]') - ).find((item) => item.textContent === 'Advanced') - expect(sourcesTab).toBeDefined() - await act(async () => sourcesTab!.click()) - expect( - container.querySelector('input[placeholder="Search sync configurations..."]') - ).toHaveValue('handbook') - await click('Accounts') - expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') - }) - it('preserves source navigation and retries connection availability failures', async () => { mocks.availabilityError = new Error('Connection availability could not be loaded') await render('google_drive', '?view=sources') @@ -655,6 +531,19 @@ describe('organization provider management', () => { expect(mocks.activate).not.toHaveBeenCalled() }) + it('explains unavailable integrations instead of showing an empty setup dead end', async () => { + mocks.access = { admin: false, members: false } + mocks.overview.mockReturnValue({ + data: { providers: [{ ...provider, connectorType: 'gitlab' }] }, + }) + mocks.sources.mockReturnValue({ data: [], isPending: false }) + await render('gitlab') + expect(container.textContent).toContain('Unavailable in this deployment') + expect(container.textContent).toContain('GitLab must be configured for this deployment') + expect(container.textContent).not.toContain('No GitLab projects added') + expect(container.textContent).not.toContain('Add project') + }) + it('explains and retries Slack account lookup failures without hiding its sources', async () => { const refetch = vi.fn() mocks.access = { admin: false, members: true } @@ -677,20 +566,6 @@ describe('organization provider management', () => { expect(refetch).toHaveBeenCalledOnce() }) - it('scopes account management and connection requests to the current provider option', async () => { - await render('google_drive', '?view=accounts') - expect(mocks.sources).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ enabled: false }) - ) - expect(mocks.people).toHaveBeenCalledWith( - expect.objectContaining({ - organizationId: 'org-one', - searchConnection: { optionId: 'google-option', providerName: 'Google Drive' }, - }) - ) - }) - it.each([ { type: 'google_drive', access: { admin: true, members: true }, memberParam: false }, { type: 'gmail', access: { admin: false, members: true }, memberParam: true }, @@ -702,8 +577,7 @@ describe('organization provider management', () => { data: { providers: [{ ...provider, connectorType: type }] }, }) await render(type) - await click('Advanced') - await click('Add sync configuration') + await click(memberParam ? 'Set up member accounts' : 'Connect service account') await vi.waitFor(() => { expect(mocks.updateUrl).toHaveBeenCalled() const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) @@ -740,7 +614,7 @@ describe('organization provider management', () => { slackBotCredentialId: 'slack-bot', configurationStatus: 'needs_update', }, - ])('offers Slack Accounts recovery for an active option with $name', async (option) => { + ])('offers Slack app recovery for an active option with $name', async (option) => { mocks.access = { admin: false, members: true } mocks.overview.mockReturnValue({ data: { providers: [{ ...provider, connectorType: 'slack' }] }, @@ -762,24 +636,21 @@ describe('organization provider management', () => { }, isPending: false, }) - await render('slack', '?view=accounts&credential-group-people=alex') + await render('slack') expect(mocks.people).not.toHaveBeenCalled() - expect(container.textContent).toContain('Set up the Slack app to connect accounts.') - expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') + expect(container.textContent).toContain('Set up Slack app') await click('Set up Slack app') await act(async () => { await vi.waitFor(() => expect(mocks.updateUrl).toHaveBeenCalled()) }) const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) expect(query.get('connectedAccounts')).toBe('slack') - expect(query.get('view')).toBe('accounts') - expect(query.get('credential-group-people')).toBe('alex') expect(query.has('addConnector')).toBe(false) }) it.each([undefined, 'slack-bot'])( - 'uses verified configuration for account management with bot credential %s', + 'allows source setup with verified Slack configuration and bot credential %s', async (slackBotCredentialId) => { mocks.access = { admin: false, members: true } mocks.overview.mockReturnValue({ @@ -804,11 +675,8 @@ describe('organization provider management', () => { }) await render('slack', '?view=accounts') - expect(mocks.people).toHaveBeenCalledWith( - expect.objectContaining({ - searchConnection: { optionId: 'slack-option', providerName: 'Slack' }, - }) - ) + expect(container.textContent).toContain('Add channels or DMs') + expect(mocks.people).not.toHaveBeenCalled() expect(container.textContent).not.toContain('Set up the Slack app to connect accounts.') } ) diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx index cbf18f2f4e0..05b2e177c68 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx @@ -1,28 +1,20 @@ 'use client' import { useState } from 'react' -import { ChipConfirmModal, ChipModalError, ChipSwitch } from '@sim/emcn' +import { ChipConfirmModal, ChipModalError } from '@sim/emcn' import { ArrowLeft, Plus } from '@sim/emcn/icons' import { format } from 'date-fns' import { useRouter } from 'next/navigation' import { useQueryState, useQueryStates } from 'nuqs' import type { SettingsAction } from '@/components/settings/settings-header' import { SettingsPanel } from '@/components/settings/settings-panel' -import { findCredentialGroupProviderFromProviderId } from '@/lib/credential-groups/providers' import { organizationRoutes } from '@/lib/navigation/paths' -import { getServiceConfigByProviderId, getServiceConfigByServiceId } from '@/lib/oauth' -import { - canConnectPersonally, - canConnectWithDefaults, - getConnectorAccessAvailability, -} from '@/lib/sim-search/connectors' +import { getSearchConnectionLabels } from '@/lib/sim-search/connection-labels' +import { getConnectorAccessAvailability } from '@/lib/sim-search/connectors' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { organizationSearchStatusLabel } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-status' -import { - connectedAccountsParam, - organizationProviderTabParam, -} from '@/app/o/[organizationId]/settings/components/integrations/search-params' +import { connectedAccountsParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params' import { OrganizationSlackAccountRemoval } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-removal' import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup' import { SearchSourcePagination } from '@/app/workspace/[workspaceId]/search/components/search-source-pagination' @@ -41,12 +33,10 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' -import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people' import { useOrganizationSearchOverview, useSearchSources } from '@/hooks/queries/kb/connectors' import { useOrganizationAccounts } from '@/hooks/queries/organization-accounts' import { useUpdateSearchIntegration } from '@/hooks/queries/search-integrations' import { useDebounce } from '@/hooks/use-debounce' -import { useOrganizationAccountPeopleSearch } from '@/hooks/use-organization-account-people-search' import { usePermissionConfig } from '@/hooks/use-permission-config' interface OrganizationProviderDetailProps { @@ -57,31 +47,21 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid const { organization, viewer, searchAccess } = useOrganizationContext() const router = useRouter() const meta = CONNECTOR_META_REGISTRY[connectorType] - const automaticSetup = Boolean(meta && canConnectWithDefaults(meta) && searchAccess.memberScoped) - const [view, setView] = useQueryState( - organizationProviderTabParam.key, - organizationProviderTabParam.parser - ) const [search, setSearch] = useSettingsSearch() - const [peopleSearch, setPeopleSearch] = useOrganizationAccountPeopleSearch() const sourceSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS) const [deactivating, setDeactivating] = useState(false) const [removingSlackAccounts, setRemovingSlackAccounts] = useState(false) const scope = { kind: 'organization', organizationId: organization.id } as const - const personal = Boolean(meta && canConnectPersonally(meta) && searchAccess.memberScoped) - const showAccounts = view === 'accounts' && personal const overview = useOrganizationSearchOverview(organization.id, { enabled: viewer.isAdmin }) const sources = useSearchSources(scope, { connectorType, search: sourceSearch, - enabled: viewer.isAdmin && !showAccounts, + enabled: viewer.isAdmin, }) const availability = usePermissionConfig() const approval = useUpdateSearchIntegration() const accounts = useOrganizationAccounts( - viewer.isAdmin && (connectorType === 'slack' || (personal && showAccounts)) - ? organization.id - : undefined + viewer.isAdmin && connectorType === 'slack' ? organization.id : undefined ) const [, setSetup] = useQueryStates( { @@ -103,19 +83,11 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid router.push(organizationRoutes(organization.id).settingsSection('integrations')), } if (!viewer.isAdmin || !meta) return null - const searchField = showAccounts - ? { value: peopleSearch, onChange: setPeopleSearch, placeholder: 'Search people...' } - : { - value: search, - onChange: setSearch, - placeholder: automaticSetup ? 'Search sync configurations...' : 'Search sources...', - } - const panel = { - back, - title: meta.name, - description: provider ? organizationSearchStatusLabel(provider) : undefined, - docsLink: meta.searchDocsUrl, - search: searchField, + const labels = getSearchConnectionLabels(connectorType) + const searchField = { + value: search, + onChange: setSearch, + placeholder: labels.searchPlaceholder, } const access = getConnectorAccessAvailability(meta, availability.integrationAvailability, { memberAccessAvailable: searchAccess.memberScoped, @@ -123,16 +95,22 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid oauthServiceAvailability: availability.oauthServiceAvailability, isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady, }) - const service = - meta.auth.mode === 'oauth' - ? (getServiceConfigByServiceId(meta.auth.provider) ?? - getServiceConfigByProviderId(meta.auth.provider)) - : undefined - const credentialProvider = service - ? findCredentialGroupProviderFromProviderId(service.providerId) - : undefined + const unavailable = + availability.isIntegrationAvailabilityReady && !access.admin && !access.members + const panel = { + back, + title: meta.name, + description: + approved && unavailable + ? 'Unavailable in this deployment' + : provider + ? organizationSearchStatusLabel(provider) + : undefined, + docsLink: meta.searchDocsUrl, + search: searchField, + } const option = accounts.data?.credentialGroup?.options.find( - (item) => item.provider === credentialProvider && item.status === 'active' + (item) => item.provider === 'slack' && item.status === 'active' ) const group = accounts.data?.credentialGroup const removalActions: SettingsAction[] = @@ -141,8 +119,8 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid group?.options.some((item) => item.provider === 'slack') ? [ { - text: 'Remove account setup', - textTone: 'error', + id: 'delete', + text: 'Remove app setup', disabled: accounts.isFetching, onSelect: () => setRemovingSlackAccounts(true), }, @@ -165,14 +143,13 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid approval.mutate({ organizationId: organization.id, connectorType, approved: true }) const actions: SettingsAction[] = approved ? [ - ...((access.admin || access.members) && (!automaticSetup || !showAccounts) + ...(access.admin || access.members ? [ { text: needsSlackSetup ? 'Set up Slack app' - : automaticSetup - ? 'Add sync configuration' - : 'Add source', + : getSearchConnectionLabels(connectorType, access.admin ? 'admin' : 'members') + .add, icon: Plus, variant: 'primary' as const, disabled: @@ -196,6 +173,7 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid text: provider ? 'Activate' : 'Add integration', variant: 'primary', disabled: pending || (!access.admin && !access.members), + tooltip: unavailable ? 'This integration is unavailable in this deployment.' : undefined, onSelect: activate, }, ] @@ -247,13 +225,13 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid {sources.isError && !sources.isFetchNextPageError ? ( void sources.refetch()} variant='inline' /> ) : sources.isPending ? ( - Loading sources… + Loading connections… ) : (
{sources.data?.map((source) => ( @@ -291,12 +269,12 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid {!sources.data?.length && !sources.hasNextPage && ( {sourceSearch - ? 'No matching sources' - : !approved - ? 'Activate this integration to set up sources.' - : automaticSetup - ? 'No sync configurations yet.' - : 'No sources yet.'} + ? 'No matching connections' + : unavailable + ? `${meta.name} must be configured for this deployment before you can add a connection.` + : !approved + ? 'Activate this integration to add a connection.' + : labels.empty} )} @@ -306,81 +284,7 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid ) return ( <> -
- {personal && ( -
- void setView(value)} - options={ - automaticSetup - ? [ - { value: 'accounts', label: 'Accounts' }, - { value: 'sources', label: 'Advanced' }, - ] - : [ - { value: 'sources', label: 'Sources' }, - { value: 'accounts', label: 'Accounts' }, - ] - } - /> -
- )} - {showAccounts ? ( - accounts.isError ? ( - - void accounts.refetch()} - variant='inline' - /> - - ) : accounts.isPending ? ( - - Loading accounts… - - ) : option && approved && !needsSlackSetup ? ( - - ) : ( - - {approval.error && ( - - {approval.error.message} - - )} - {availability.integrationAvailabilityError && ( - void availability.refetchIntegrationAvailability()} - variant='inline' - /> - )} - - {approved - ? needsSlackSetup - ? 'Set up the Slack app to connect accounts.' - : automaticSetup - ? provider && provider.sourceCount > 0 - ? 'No connected member accounts.' - : 'Members connect their accounts from Integrations. Indexing starts automatically.' - : 'Add a source to set up account connections.' - : 'Activate this integration to set up account connections.'} - - - ) - ) : ( - renderSources() - )} -
+ {renderSources()} { ) }) - 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( @@ -239,8 +223,10 @@ describe('organization source detail navigation', () => { }) await render(searchParams) - expect(container.textContent).toContain('Some source updates are incomplete') - expect(container.textContent).toContain('Review the source settings and try syncing again.') + expect(container.textContent).toContain('Some connection updates are incomplete') + expect(container.textContent).toContain( + 'Review the connection settings and try syncing again.' + ) expect(container.textContent).not.toContain('Provider denied') expect(container.textContent).not.toContain('org-private-id') } @@ -248,8 +234,10 @@ describe('organization source detail navigation', () => { it('keeps a healthy active source quiet', async () => { await render() - expect(container.textContent).not.toContain('Some source updates are incomplete') - expect(container.textContent).not.toContain('Review the source settings and try syncing again.') + expect(container.textContent).not.toContain('Some connection updates are incomplete') + expect(container.textContent).not.toContain( + 'Review the connection settings and try syncing again.' + ) }) it.each(['', '?view=settings', '?view=history'])( @@ -293,7 +281,7 @@ describe('organization source detail navigation', () => { async (status) => { mocks.detail.mockReturnValue({ data: { ...connector, status, lastSyncError: 'Old failure' } }) await render() - expect(container.textContent).not.toContain('Some source updates are incomplete') + expect(container.textContent).not.toContain('Some connection updates are incomplete') } ) @@ -319,7 +307,9 @@ describe('organization source detail navigation', () => { }, }) await render() - expect(container.textContent?.includes('Some source updates are incomplete')).toBe(showNotice) + expect(container.textContent?.includes('Some connection updates are incomplete')).toBe( + showNotice + ) expect(container.textContent).not.toContain('private-account-id') } ) @@ -351,7 +341,7 @@ describe('organization source detail navigation', () => { await render() expect(mocks.actions).not.toHaveBeenCalled() expect(mocks.documents).not.toHaveBeenCalled() - expect(container.textContent).toContain('Loading source') + expect(container.textContent).toContain('Loading connection') }) it('does not load protected source data for non-admins', async () => { mocks.admin = false @@ -368,7 +358,7 @@ describe('organization source detail navigation', () => { mocks.index.mockReturnValue({ data: { knowledgeBaseId: null }, isPending: false }) mocks.detail.mockReturnValue({}) await render() - expect(container.textContent).toContain('This source is no longer available') + expect(container.textContent).toContain('This connection is no longer available') expect(mocks.actions).not.toHaveBeenCalled() await click('Sources') expect(mocks.push).toHaveBeenCalledWith('/o/org-one/settings/integrations') @@ -402,7 +392,7 @@ describe('organization source detail navigation', () => { const sync = Array.from(container.querySelectorAll('button')).find( (item) => item.textContent === 'Sync now' ) - const tabs = container.querySelector('[aria-label="Source views"]') + const tabs = container.querySelector('[aria-label="Connection views"]') expect(sync).toBeTruthy() expect(tabs).toBeTruthy() expect(sync!.compareDocumentPosition(tabs!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) 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 1439c907041..939dfd52563 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 @@ -70,7 +70,7 @@ export function OrganizationSourceDetail({ connectorId }: OrganizationSourceDeta if (!viewer.isAdmin) return ( - + Only organization admins can manage sources. @@ -89,17 +89,17 @@ export function OrganizationSourceDetail({ connectorId }: OrganizationSourceDeta ) if (failedQuery && (!hasCanonicalDetail || accessFailure)) return ( - + {isApiClientError(failedQuery.error) && failedQuery.error.status === 404 ? ( - This source is no longer available. + This connection is no longer available. ) : ( void failedQuery.refetch()} - fallback='Could not load source' + fallback='Could not load connection' variant='inline' /> )} @@ -107,9 +107,9 @@ export function OrganizationSourceDetail({ connectorId }: OrganizationSourceDeta ) if (!index.isPending && !knowledgeBaseId) return ( - + - This source is no longer available. + This connection is no longer available. ) @@ -120,8 +120,8 @@ export function OrganizationSourceDetail({ connectorId }: OrganizationSourceDeta detail.data.knowledgeBaseId !== knowledgeBaseId ) return ( - - Loading source… + + Loading connection… ) return ( @@ -137,7 +137,7 @@ export function OrganizationSourceDetail({ connectorId }: OrganizationSourceDeta error={failedQuery.error} isRetrying={failedQuery.isFetching} onRetry={() => void failedQuery.refetch()} - fallback='Could not refresh source' + fallback='Could not refresh connection' variant='inline' /> ) : undefined @@ -184,7 +184,7 @@ function SourceDetailContent({ const meta = CONNECTOR_META_REGISTRY[connector.connectorType] const title = meta ? describeSearchSource(meta, connector.sourceConfig) || meta.name - : 'Search source' + : 'Connection' const { effectiveStatus, lastSyncError } = getConnectorSyncState(connector) const status = effectiveStatus === 'paused' @@ -209,7 +209,7 @@ function SourceDetailContent({ return ( )} - {connector.accessMode === 'members' && ( - - )} ) if (view === 'settings') @@ -279,8 +270,8 @@ function SourceDetailContent({ {effectiveStatus === 'active' && lastSyncError && ( )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection.tsx index e1df655f9ce..5428ce8551d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection.tsx @@ -53,7 +53,8 @@ function SearchIntegrationConnectionControl({ }) const name = connector?.meta.name ?? target.provider const action = target.credentialId ? 'Reconnect' : 'Connect' - const label = connection.connected + const connected = connection.connected + const label = connected ? `Connected ${name}` : connection.isLoading ? `Checking ${name} connections…` @@ -76,18 +77,14 @@ function SearchIntegrationConnectionControl({ !connector || connection.isLoading || connection.isStarting || - connection.connected || + connected || (!connection.available && !connection.pending) } onClick={handleConnect} leading={ connector && } - trailing={ - connection.connected ? ( - - ) : undefined - } + trailing={connected ? : undefined} /> {connection.pending && Cancel} {connection.error && ( @@ -100,6 +97,8 @@ function SearchIntegrationConnectionControl({ )} {setupOpen && connector && ( setSetupOpen(false)} isPending={connection.isStarting} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/atlassian-source-setup-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/atlassian-source-setup-modal.test.tsx new file mode 100644 index 00000000000..44e7fe18616 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/atlassian-source-setup-modal.test.tsx @@ -0,0 +1,172 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PersonalSourceSetupAccounts } from '@/lib/api/contracts/knowledge/personal-source-setup' +import type { ConnectorConfigField } from '@/connectors/types' + +const m = vi.hoisted(() => ({ + connect: vi.fn(), + authorize: vi.fn(), + close: vi.fn(), + complete: vi.fn(), + accounts: [] as PersonalSourceSetupAccounts['accounts'], + pending: false, +})) +vi.mock('@/hooks/use-personal-source-account', () => ({ + usePersonalSourceAccount: () => ({ + accounts: { + data: { accounts: m.accounts, completedCredentialId: null }, + isPending: false, + isError: false, + }, + connect: m.authorize, + cancel: vi.fn(), + pending: m.pending, + }), +})) +vi.mock('@/hooks/queries/personal-source-setup', () => ({ + useConnectPersonalSourceSetup: () => ({ mutateAsync: m.connect, isPending: false }), +})) +vi.mock( + '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field', + () => ({ + ConnectorSelectorField: (props: { + field: ConnectorConfigField + value: string[] + onChange: (value: string[]) => void + credentialId: string + selectorSurface: { kind: string } + }) => ( + + ), + }) +) + +import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' +import { AtlassianSourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/atlassian-source-setup-modal' + +let root: Root +let container: HTMLDivElement +function button(label: string) { + const found = Array.from(document.querySelectorAll('button')).find( + (node) => node.textContent?.trim() === label + ) + if (!found) throw new Error(`Missing button: ${label}`) + return found +} +function input(placeholder: string) { + const found = document.querySelector(`input[placeholder="${placeholder}"]`) + if (!found) throw new Error(`Missing input: ${placeholder}`) + return found +} +function fill(field: HTMLInputElement, value: string) { + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(field, value) + field.dispatchEvent(new Event('input', { bubbles: true })) + }) +} +function render(type: 'jira' | 'confluence' = 'jira') { + const connector = SEARCH_CONNECTORS.find((item) => item.type === type)! + act(() => + root.render( + + ) + ) +} +beforeEach(() => { + vi.clearAllMocks() + m.accounts = [] + m.pending = false + m.connect.mockResolvedValue({ + kind: 'connected', + connectorId: 'source-1', + knowledgeBaseId: 'kb-1', + }) + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) +describe('Atlassian personal source setup', () => { + it('starts with account authorization and hides content fields until an account exists', () => { + render() + expect(document.querySelector('[data-testid="picker"]')).toBeNull() + expect(button('Connect & Sync')).toBeDisabled() + act(() => button('Connect account').click()) + expect(m.authorize).toHaveBeenCalledOnce() + expect(m.connect).not.toHaveBeenCalled() + }) + it.each(['jira', 'confluence'] as const)( + 'reuses an account and preserves selected %s keys through manual mode and submission', + async (type) => { + m.accounts = [ + { id: 'my-account', name: 'My account', provider: type, type: 'managed_oauth', scopes: [] }, + ] + render(type) + fill(input('yoursite.atlassian.net'), 'team.atlassian.net') + const picker = document.querySelector('[data-testid="picker"]')! + expect(picker.dataset.credential).toBe('my-account') + expect(picker.dataset.surface).toBe('personal-search-setup') + act(() => picker.click()) + act(() => button('Enter keys manually').click()) + const manual = document.querySelector( + `input[aria-label="${type === 'jira' ? 'Project Keys' : 'Space Keys'}"]` + )! + expect(manual.value).toBe('ENG, SUPPORT') + fill(manual, 'ENG, PRODUCT') + act(() => button(`Choose ${type === 'jira' ? 'projects' : 'spaces'} from list`).click()) + expect(document.querySelector('[data-testid="picker"]')?.textContent).toBe('ENG,PRODUCT') + await act(async () => button('Connect & Sync').click()) + expect(m.connect).toHaveBeenCalledWith({ + action: 'connect', + organizationId: 'org-1', + connectorType: type, + credentialId: 'my-account', + domain: 'team.atlassian.net', + keys: ['ENG', 'PRODUCT'], + }) + expect(m.authorize).not.toHaveBeenCalled() + expect(m.complete).toHaveBeenCalledWith({ + connectorId: 'source-1', + credentialId: 'my-account', + }) + } + ) + it('clears the selection when the site changes', () => { + m.accounts = [ + { id: 'my-account', name: 'My account', provider: 'jira', type: 'managed_oauth', scopes: [] }, + ] + render() + fill(input('yoursite.atlassian.net'), 'team.atlassian.net') + act(() => document.querySelector('[data-testid="picker"]')!.click()) + fill(input('yoursite.atlassian.net'), 'other.atlassian.net') + expect(button('Connect & Sync')).toBeDisabled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/atlassian-source-setup-modal.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/atlassian-source-setup-modal.tsx new file mode 100644 index 00000000000..4b60deaa7cb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/atlassian-source-setup-modal.tsx @@ -0,0 +1,241 @@ +'use client' + +import { useState } from 'react' +import { + Chip, + ChipCombobox, + ChipInput, + ChipModal, + ChipModalBody, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + toast, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import type { PersonalSourceSetupQuery } from '@/lib/api/contracts/knowledge/personal-source-setup' +import type { SearchConnector } from '@/lib/sim-search/connectors' +import { MAX_PERSONAL_SOURCE_SETUP_KEYS } from '@/lib/sim-search/personal-source-setup' +import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field' +import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' +import { useConnectPersonalSourceSetup } from '@/hooks/queries/personal-source-setup' +import { usePersonalSourceAccount } from '@/hooks/use-personal-source-account' + +interface AtlassianSourceSetupModalProps { + organizationId: string + connector: SearchConnector + connectorType: PersonalSourceSetupQuery['connectorType'] + onClose: () => void + onConnected?: (connection: { connectorId: string; credentialId: string }) => void +} + +export function AtlassianSourceSetupModal({ + organizationId, + connector, + connectorType, + onClose, + onConnected, +}: AtlassianSourceSetupModalProps) { + const account = usePersonalSourceAccount({ + organizationId, + connectorType, + onConnected: (id) => { + setSelectedAccount(id) + config.setSourceConfig((previous) => ({ domain: previous.domain ?? '' })) + }, + }) + const { mutateAsync: connect, isPending } = useConnectPersonalSourceSetup() + const config = useConnectorConfigFields({ + connectorConfig: connector.meta, + accessMode: 'members', + }) + const [selectedAccount, setSelectedAccount] = useState() + const accounts = account.accounts.data?.accounts ?? [] + const requestedAccount = + selectedAccount ?? + account.accounts.data?.completedCredentialId ?? + (accounts.length === 1 ? accounts[0].id : undefined) + const credentialId = accounts.find((item) => item.id === requestedAccount)?.id ?? null + const canonicalId = connectorType === 'jira' ? 'projectKey' : 'spaceKey' + const picker = connector.meta.configFields.find( + (field) => field.canonicalParamId === canonicalId && field.type === 'selector' + )! + const manual = connector.meta.configFields.find((field) => field.id === canonicalId)! + const advanced = config.canonicalModes[canonicalId] === 'advanced' + const domain = typeof config.sourceConfig.domain === 'string' ? config.sourceConfig.domain : '' + const resolved = config.resolveSourceConfig()[canonicalId] + const manualValue = config.sourceConfig[canonicalId] + const keys = Array.isArray(resolved) + ? resolved + .filter((key): key is string => typeof key === 'string' && Boolean(key.trim())) + .map((key) => key.trim()) + : [] + const pending = isPending || account.pending + const close = () => { + if (!isPending) onClose() + } + const chooseAccount = (id: string) => { + if (id === credentialId) return + setSelectedAccount(id) + config.setSourceConfig({ domain }) + } + const addAccount = () => { + void account.connect() + } + const submit = async () => { + if (!credentialId || !domain.trim() || !keys.length || pending) return + if (keys.length > MAX_PERSONAL_SOURCE_SETUP_KEYS) { + toast.error('Choose no more than 1,000 projects or spaces per source.') + return + } + try { + const result = await connect({ + action: 'connect', + organizationId, + connectorType, + credentialId, + domain: domain.trim(), + keys, + }) + onConnected?.({ connectorId: result.connectorId, credentialId }) + onClose() + } catch (error) { + toast.error(getErrorMessage(error, 'Could not connect the source')) + } + } + + return ( + { + if (!open) close() + }} + srTitle={`Connect ${connector.meta.name}`} + > + Connect {connector.meta.name} + + + {(aria) => ( + <> + {accounts.length > 0 && ( + ({ value: item.id, label: item.name }))} + placeholder='Select your account' + disabled={pending} + /> + )} +
+ + {account.pending + ? 'Waiting for authorization…' + : accounts.length + ? 'Connect another account' + : 'Connect account'} + + {account.pending && Cancel} +
+ {account.accounts.isPending && ( +

Loading accounts…

+ )} + {account.accounts.isError && ( + void account.accounts.refetch()}>Retry loading accounts + )} + + )} +
+ {credentialId && !account.pending && ( + <> + config.handleFieldChange('domain', value)} + placeholder='yoursite.atlassian.net' + autoComplete='off' + required + disabled={isPending} + /> + + {(aria) => ( + <> + {advanced ? ( + + config.handleFieldChange(canonicalId, event.target.value) + } + placeholder={manual.placeholder} + disabled={isPending} + /> + ) : picker.selectorKey ? ( + + config.handleFieldChange(picker.id, value, labels) + } + credentialId={credentialId} + sourceConfig={config.sourceConfig} + configFields={connector.meta.configFields} + canonicalModes={config.canonicalModes} + selectedLabels={config.selectionLabels[canonicalId]} + disabled={isPending} + /> + ) : null} + config.toggleCanonicalMode(canonicalId)} + disabled={isPending} + > + {advanced + ? `Choose ${connectorType === 'jira' ? 'projects' : 'spaces'} from list` + : 'Enter keys manually'} + + + )} + + + )} +
+ + window.open(connector.meta.searchDocsUrl, '_blank', 'noopener,noreferrer'), + }, + ] + : undefined + } + primaryAction={{ + label: isPending ? 'Connecting…' : 'Connect & Sync', + onClick: () => void submit(), + disabled: !credentialId || !domain.trim() || !keys.length || pending, + }} + /> +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal.tsx index 4b73d5b989c..eb6f15699a4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal.tsx @@ -9,8 +9,11 @@ import { ChipModalHeader, } from '@sim/emcn' import type { SearchConnector } from '@/lib/sim-search/connectors' +import { AtlassianSourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/atlassian-source-setup-modal' interface SourceSetupModalProps { + organizationId?: string + onConnected?: (connection: { connectorId: string; credentialId: string }) => void connector: SearchConnector onClose: () => void isPending?: boolean @@ -23,7 +26,26 @@ interface SourceSetupModalProps { * The few fields a source needs before its first connect, such as a site and * a space. Everyone after the first person clicks straight through. */ -export function SourceSetupModal({ +export function SourceSetupModal(props: SourceSetupModalProps) { + if ( + props.organizationId && + (props.connector.type === 'jira' || props.connector.type === 'confluence') + ) { + return ( + + ) + } + return +} + +function ManualSourceSetupModal({ connector, onClose, onConnect, 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 2dbccfee687..daee1e5c632 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 @@ -13,6 +13,7 @@ import type { ConfigFieldMap } from '@/app/workspace/[workspaceId]/knowledge/[id const mocks = vi.hoisted(() => ({ create: vi.fn(), accountsQuery: vi.fn(), + oauthQuery: vi.fn(), configFields: vi.fn(), resolveSourceConfig: vi.fn((): Record => ({})), sourceConfig: {} as ConfigFieldMap, @@ -130,14 +131,17 @@ vi.mock('@/hooks/queries/source-accounts', () => ({ }, })) vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({ - useOAuthCredentials: () => ({ - data: mocks.credentials, - isLoading: mocks.credentialsState === 'loading', - isSuccess: mocks.credentialsState === 'ready', - isFetching: mocks.credentialsState === 'loading', - error: mocks.credentialsState === 'error' ? new Error('Could not load accounts') : null, - refetch: mocks.refetchCredentials, - }), + useOAuthCredentials: (...args: unknown[]) => { + mocks.oauthQuery(...args) + return { + data: mocks.credentials, + isLoading: mocks.credentialsState === 'loading', + isSuccess: mocks.credentialsState === 'ready', + isFetching: mocks.credentialsState === 'loading', + error: mocks.credentialsState === 'error' ? new Error('Could not load accounts') : null, + refetch: mocks.refetchCredentials, + } + }, })) vi.mock('@/hooks/use-oauth-return', () => ({ useOAuthReturnForKBConnectors: vi.fn() })) vi.mock('@/hooks/use-credential-refresh-triggers', () => ({ @@ -158,6 +162,7 @@ vi.mock(