diff --git a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx index 3ca4d246c6f..a1247719b3a 100644 --- a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx +++ b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx @@ -203,7 +203,7 @@ Keep **Expire user authorization tokens** enabled so Sim receives the refresh to Complete the installation through [the GitHub Search source setup](/search/github#add-a-repository). -If you replace a deployment's GitHub App, an organization admin must first open **Settings → Sources → Update configurations**. When Search is disabled, this action is under **Settings → Connected accounts → Providers**. This applies the deployment's current App configuration to the existing providers while preserving their saved identities. Accounts whose App configuration changed must reconnect. Then reconnect personal GitHub accounts and connect an installation of the new App. Reconnecting alone cannot update the organization's saved App configuration. +If you replace a deployment's GitHub App, an organization admin selects **Settings → Sources → More → Refresh connection settings**. When Search is disabled, use **Connected accounts → Providers → Update configurations** in organization settings. This applies the deployment's current App configuration to all existing providers while preserving their option IDs. Accounts whose App configuration changed must reconnect. Then reconnect personal GitHub accounts and connect an installation of the new App. Reconnecting alone cannot update the organization's saved App configuration. diff --git a/apps/docs/content/docs/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx index e2480e8a74d..d171730c160 100644 --- a/apps/docs/content/docs/search/confluence.mdx +++ b/apps/docs/content/docs/search/confluence.mdx @@ -196,6 +196,6 @@ Configure one shared Confluence OAuth integration for your deployment. This powe 2. Under **Authorization → OAuth 2.0 (3LO)**, add `https:///api/auth/oauth2/callback/confluence` to **Callback URLs**, keep existing callbacks used by the deployment, and save. 3. Under **Permissions**, add the Confluence API and configure the full `confluence` scope list for your release in [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts), including `read:group:confluence`. Also add **User Identity API** with `read:me`. Sim requests `offline_access` for refresh tokens. The service-account read scopes above do not replace the broader shared OAuth scope set. 4. Enable sharing under **Distribution**. Set `CONFLUENCE_CLIENT_ID` and `CONFLUENCE_CLIENT_SECRET` from the app's **Settings**, verify `NEXT_PUBLIC_APP_URL`, and restart Sim. -5. Start authorization from **Integrations** and select the configured site. After changing the deployment's OAuth client or requested scopes, an organization admin selects **Settings → Sources → Update configurations**, then affected teammates reconnect. +5. Start authorization from **Integrations** and select the configured site. After changing the deployment's OAuth client or requested scopes, an organization admin selects **Settings → Sources → More → Refresh connection settings**, then affected teammates reconnect. A callback mismatch needs a corrected callback URL; a connection that works only for the app owner needs sharing enabled. See Atlassian's [OAuth configuration guide](https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/) and Sim's [deployment reference](/platform/self-hosting/integrations-oauth). diff --git a/apps/docs/content/docs/search/github.mdx b/apps/docs/content/docs/search/github.mdx index cf3f2858cc9..53a4d329ad9 100644 --- a/apps/docs/content/docs/search/github.mdx +++ b/apps/docs/content/docs/search/github.mdx @@ -96,7 +96,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). | -| Update GitHub using Update configurations in organization settings before connecting this source | An organization admin must select **Settings → Sources → Update configurations**, then reconnect GitHub. | +| GitHub asks for a provider configuration update | An organization admin selects **Settings → Sources → More → Refresh connection 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/gmail.mdx b/apps/docs/content/docs/search/gmail.mdx index 94fd942d937..97ee3fb1b5c 100644 --- a/apps/docs/content/docs/search/gmail.mdx +++ b/apps/docs/content/docs/search/gmail.mdx @@ -97,7 +97,7 @@ Users do not need to create Google Cloud credentials. The deployment operator co 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 → Update configurations**, 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 → Refresh connection 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 60a676b3f45..020039b20ab 100644 --- a/apps/docs/content/docs/search/google-calendar.mdx +++ b/apps/docs/content/docs/search/google-calendar.mdx @@ -94,7 +94,7 @@ The deployment operator configures Google OAuth once; teammates then use the nor 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 → Update configurations**, 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 → Refresh connection 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 d502865f1d4..230449ffbb2 100644 --- a/apps/docs/content/docs/search/google-drive.mdx +++ b/apps/docs/content/docs/search/google-drive.mdx @@ -151,7 +151,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 → Update configurations**, 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 → Refresh connection 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 6119f99440b..104f2f83852 100644 --- a/apps/docs/content/docs/search/index.mdx +++ b/apps/docs/content/docs/search/index.mdx @@ -18,7 +18,7 @@ Search brings your connected sources into one place. An organization admin allow As an organization admin, open **Settings → Sources** and turn on the integration. It stays in the list; no setup page opens automatically. -The switch 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. +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. @@ -41,9 +41,9 @@ Open **Integrations** in the main sidebar and select **Connect** if prompted—e -Organization Sources settings with provider switches, Set up or Manage actions, and Update configurations +Organization Sources settings with provider switches and Set up or Manage actions -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, an admin selects **Update configurations** 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 → Refresh connection settings** on this page, then affected teammates reconnect. ## Choose the right connection method @@ -100,7 +100,7 @@ For Gmail, one configuration is usually enough. **Add sync configuration** creat | Source 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. Save your changes before leaving. | +| **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**. diff --git a/apps/docs/content/docs/search/jira.mdx b/apps/docs/content/docs/search/jira.mdx index ea3e1e960c1..7306de72675 100644 --- a/apps/docs/content/docs/search/jira.mdx +++ b/apps/docs/content/docs/search/jira.mdx @@ -138,6 +138,6 @@ The deployment operator configures one shared Jira OAuth integration. Teammates 3. Under **Permissions**, add **Jira API**, then **Configure** its classic and granular scopes for Jira, Jira Service Management, and Assets. Separately add **User Identity API** with `read:me`. Sim requests `offline_access` in the authorization URL for refresh tokens. Configure the full `jira` scope list for your release in [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts); the Search read scopes above are only a subset of this shared integration's permissions. 4. Under **Distribution**, enable sharing so teammates can authorize the app. Copy the client ID and secret from **Settings** into `JIRA_CLIENT_ID` and `JIRA_CLIENT_SECRET`, set the correct `NEXT_PUBLIC_APP_URL`, and restart Sim. -5. Start a connection from **Integrations**. Confirm that Atlassian lists the intended site, then return to Sim. After changing the deployment's OAuth client or requested scopes, an organization admin selects **Settings → Sources → Update configurations**, then affected teammates reconnect. +5. Start a connection from **Integrations**. Confirm that Atlassian lists the intended site, then return to Sim. After changing the deployment's OAuth client or requested scopes, an organization admin selects **Settings → Sources → More → Refresh connection settings**, then affected teammates reconnect. For a local instance using `NEXT_PUBLIC_APP_URL=http://localhost:3000`, register `http://localhost:3000/api/auth/oauth2/callback/jira`. Use a separate development OAuth app when production callbacks must remain unchanged. After updating local client credentials or the app URL, restart Sim and begin a new connection from **Integrations**. If only the app owner can connect, check **Distribution**. See Atlassian's [OAuth configuration and sharing guide](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) and Sim's [deployment reference](/platform/self-hosting/integrations-oauth). diff --git a/apps/docs/public/static/search/integration-settings.jpg b/apps/docs/public/static/search/integration-settings.jpg index 05d4e20bebe..f4e517c0656 100644 Binary files a/apps/docs/public/static/search/integration-settings.jpg and b/apps/docs/public/static/search/integration-settings.jpg differ diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index 94f2cf1a7ab..1a0e2a60de4 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -38,6 +38,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +import { getCanonicalScopesForProvider, getMissingRequiredScopes } from '@/lib/oauth/utils' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { GET } from '@/app/api/auth/oauth/credentials/route' @@ -146,6 +147,100 @@ describe('OAuth Credentials API Route', () => { await expect(response.json()).resolves.toEqual({ credentials: [] }) }) + describe.each(['list', 'detail'] as const)('OAuth grant scopes in %s responses', (mode) => { + const workspaceId = '3f1c8a54-1c2e-4a1b-9d6e-2b7c5a9f0e11' + + beforeEach(() => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockReset().mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'session', + }) + permissionsMockFns.mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue( + DEFAULT_PERMISSION_GROUP_CONFIG + ) + }) + + async function requestCredential(providerId: string, scope: string | null) { + const row = { + id: 'credential-1', + workspaceId, + type: 'oauth', + displayName: 'Connected account', + providerId, + accountId: 'account-1', + scope, + updatedAt: new Date('2026-01-01T00:00:00Z'), + accountProviderId: providerId, + accountScope: scope, + accountUpdatedAt: new Date('2026-01-01T00:00:00Z'), + } + if (mode === 'detail') { + dbChainMockFns.limit.mockResolvedValueOnce([row]) + } else { + dbChainMockFns.where.mockResolvedValueOnce([row]).mockResolvedValueOnce([]) + } + const query = + mode === 'detail' + ? '?credentialId=credential-1' + : `?provider=${providerId}&workspaceId=${workspaceId}` + const response = await GET(createMockRequestWithQuery('GET', query)) + expect(response.status).toBe(200) + const data = await response.json() + expect(data.credentials).toHaveLength(1) + return data.credentials[0] + } + + it.each([null, '', ' \t\n '])( + 'does not synthesize a Confluence grant from missing scope metadata %j', + async (scope) => { + const credential = await requestCredential('confluence', scope) + + expect(credential.scopes).toEqual([]) + expect( + getMissingRequiredScopes(credential, getCanonicalScopesForProvider('confluence')) + ).toContain('read:group:confluence') + } + ) + + it('preserves the actual older Confluence grant and identifies missing group access', async () => { + const requiredScopes = getCanonicalScopesForProvider('confluence') + const previousGrant = requiredScopes.filter((scope) => scope !== 'read:group:confluence') + const credential = await requestCredential('confluence', previousGrant.join(',')) + + expect(credential.scopes).toEqual(previousGrant) + expect(getMissingRequiredScopes(credential, requiredScopes)).toEqual([ + 'read:group:confluence', + ]) + }) + + it('preserves a complete Confluence grant without requesting another update', async () => { + const grantedScopes = getCanonicalScopesForProvider('confluence') + const credential = await requestCredential('confluence', grantedScopes.join(' ')) + + expect(credential.scopes).toEqual(grantedScopes) + expect(getMissingRequiredScopes(credential, grantedScopes)).toEqual([]) + }) + + it.each([null, '', ' \t\n '])( + 'preserves the Box omitted-scope fallback for %j', + async (scope) => { + const credential = await requestCredential('box', scope) + const requiredScopes = getCanonicalScopesForProvider('box') + + expect(requiredScopes.length).toBeGreaterThan(0) + expect(credential.scopes).toEqual(requiredScopes) + expect(getMissingRequiredScopes(credential, requiredScopes)).toEqual([]) + } + ) + }) + /** The session/executor split documented on {@link integrationsWithheldFromSession} in the route. */ describe('integrations.manage', () => { const INTEGRATIONS_WITHHELD = { diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index 0625008f233..318e7c7ac98 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -35,13 +35,15 @@ function toCredentialResponse( credentialType: 'oauth' | 'service_account' = 'oauth' ) { const storedScope = scope?.trim() - // Some providers (e.g. Box) don't return scopes in their token response, - // so the DB column stays empty. Fall back to the configured scopes for - // the provider so the credential-selector doesn't show a false - // "Additional permissions required" banner. + /** + * Confluence reports granted scopes, so absent metadata must prompt reauthorization. + * Preserve the existing fallback for providers that omit scopes, such as Box. + */ const scopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) - : getCanonicalScopesForProvider(providerId) + : providerId === 'confluence' + ? [] + : getCanonicalScopesForProvider(providerId) const [_, featureType = 'default'] = providerId.split('-') return { 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 3c91acc8103..952880c34ee 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 @@ -13,6 +13,8 @@ const mocks = vi.hoisted(() => ({ refetch: vi.fn(), update: vi.fn(), updatePending: false, + updateError: null as Error | null, + resetUpdate: vi.fn(), })) vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ useOrganizationContext: mocks.context, @@ -23,7 +25,12 @@ vi.mock( ) vi.mock('@/hooks/queries/organization-accounts', () => ({ useOrganizationAccounts: mocks.accounts, - useUpdateOrganizationAccounts: () => ({ mutate: mocks.update, isPending: mocks.updatePending }), + useUpdateOrganizationAccounts: () => ({ + mutate: mocks.update, + isPending: mocks.updatePending, + error: mocks.updateError, + reset: mocks.resetUpdate, + }), useOrganizationAccountPeople: mocks.people, useInviteOrganizationAccountPeople: () => ({ mutateAsync: mocks.invite, reset: vi.fn() }), useResendOrganizationAccountInvitation: () => ({}), @@ -42,6 +49,7 @@ describe('organization integration invitations', () => { vi.spyOn(toast, 'success').mockReturnValue('toast-id') vi.spyOn(toast, 'error').mockReturnValue('toast-id') mocks.updatePending = false + mocks.updateError = null vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) mocks.context.mockReturnValue({ organization: { id: 'org-a' }, viewer: { isAdmin: true } }) mocks.accounts.mockReturnValue({ @@ -85,7 +93,7 @@ describe('organization integration invitations', () => { function findButton(label: string) { const button = Array.from(document.querySelectorAll('button')).find( - (element) => element.textContent === label + (element) => element.textContent === label || element.getAttribute('aria-label') === label ) if (!button) throw new Error(`Missing ${label} button`) return button @@ -95,6 +103,19 @@ describe('organization integration invitations', () => { await act(async () => findButton(label).click()) } + async function openRefresh() { + expect(container.textContent).not.toContain('Update configurations') + await act(async () => + findButton('More source actions').dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true, button: 0 }) + ) + ) + const item = document.querySelector('[role="menuitem"]') + expect(item?.textContent).toBe('Refresh connection settings') + await act(async () => item?.click()) + expect(document.body.textContent).toContain('Affected accounts will need to reconnect.') + } + it('keeps provider setup as the default and sends manual invitations from People to this org', async () => { await render() expect(container.textContent).toContain('Provider setup') @@ -124,7 +145,7 @@ describe('organization integration invitations', () => { expect(document.querySelector('[role="dialog"]')).toBeNull() }) - it('refreshes saved provider identities from Sources and reports the outcome', async () => { + it('refreshes saved provider identities only after choosing the maintenance action and confirming', async () => { mocks.accounts.mockReturnValue({ data: { credentialGroup: { @@ -151,7 +172,8 @@ describe('organization integration invitations', () => { }) mocks.update.mockImplementationOnce((_input, { onSuccess }) => onSuccess()) await render() - await click('Update configurations') + await openRefresh() + await click('Refresh') expect(mocks.update).toHaveBeenCalledWith( { organizationId: 'org-a', @@ -176,24 +198,32 @@ describe('organization integration invitations', () => { }, expect.any(Object) ) - expect(toast.success).toHaveBeenCalledWith('Provider configurations updated') + expect(toast.success).toHaveBeenCalledWith('Connection settings refreshed') - mocks.update.mockImplementationOnce((_input, { onError }) => - onError(new Error('Update denied')) - ) - await click('Update configurations') - expect(toast.error).toHaveBeenCalledWith('Update denied') + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + it('keeps failed refreshes open for retry and blocks duplicate submissions', async () => { + mocks.accounts.mockReturnValue({ + data: { credentialGroup: { id: 'group-a', options: [{ provider: 'gmail' }] } }, + error: null, + }) + await render() + await openRefresh() + await click('Refresh') + 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('Update configurations')).toBeDisabled() - await click('Update configurations') - expect(mocks.update).toHaveBeenCalledTimes(2) + expect(findButton('Refresh')).toBeDisabled() + expect(mocks.update).toHaveBeenCalledOnce() }) - it('does not offer a configuration update without saved providers', async () => { + it('does not offer maintenance without saved providers', async () => { await render() - expect(container.textContent).not.toContain('Update configurations') + expect(container.querySelector('[aria-label="More source actions"]')).toBeNull() }) it('opens People directly from the saved URL', async () => { 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 82055465391..5a88385585a 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 @@ -1,11 +1,13 @@ 'use client' -import { Chip, ChipSwitch, toast } from '@sim/emcn' +import { useState } from 'react' +import { Chip, ChipConfirmModal, ChipModalError, ChipSwitch, toast } from '@sim/emcn' import { useQueryState } 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 { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState, SettingsQueryErrorState, @@ -24,8 +26,9 @@ export function OrganizationIntegrationsSettings() { ) const accounts = useOrganizationAccounts(viewer.isAdmin ? organization.id : undefined) const update = useUpdateOrganizationAccounts() + const [refreshOpen, setRefreshOpen] = useState(false) const group = accounts.data?.credentialGroup - const updateConfigurations = () => { + const refreshConnections = () => { if (!group || update.isPending) return update.mutate( { @@ -34,8 +37,10 @@ export function OrganizationIntegrationsSettings() { update: { options: getOrganizationAccountUpdateOptions(group) }, }, { - onSuccess: () => toast.success('Provider configurations updated'), - onError: (error) => toast.error(error.message), + onSuccess: () => { + setRefreshOpen(false) + toast.success('Connection settings refreshed') + }, } ) } @@ -54,11 +59,32 @@ export function OrganizationIntegrationsSettings() { ]} /> {tab === 'providers' && !accounts.error && group && group.options.length > 0 && ( - - Update configurations - + { + 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 }} + > + {update.error?.message} + {tab === 'providers' && } {tab === 'people' && ( { ) expect(container.textContent).not.toContain('Add source') expect(container.textContent).not.toContain('Add sync configuration') - expect(container.querySelector('a[href="/o/org-one/integrations"]')).toHaveTextContent( - 'Open Integrations' - ) + 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 }) 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 eda0203eca3..8e8003d9bfa 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,7 +1,7 @@ 'use client' import { useState } from 'react' -import { ChipConfirmModal, ChipLink, ChipModalError, ChipSwitch } from '@sim/emcn' +import { ChipConfirmModal, ChipModalError, ChipSwitch } from '@sim/emcn' import { ArrowLeft, Plus } from '@sim/emcn/icons' import { format } from 'date-fns' import { useRouter } from 'next/navigation' @@ -367,11 +367,6 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid : 'Add a source to set up account connections.' : 'Activate this integration to set up account connections.'} - {automaticSetup && approved && ( - - Open Integrations - - )} ) ) : ( diff --git a/apps/sim/lib/credential-groups/service.ts b/apps/sim/lib/credential-groups/service.ts index 78715ba0884..79f2e428378 100644 --- a/apps/sim/lib/credential-groups/service.ts +++ b/apps/sim/lib/credential-groups/service.ts @@ -307,7 +307,7 @@ export async function ensureWorkspaceAccountsGroup( throw new OrchestrationError( 'validation', scope.kind === 'organization' - ? `Update ${preparedOption.label} using Update configurations in organization settings before connecting this source` + ? `Refresh ${preparedOption.label} in Sources > More > Refresh connection settings, or Connected accounts > Providers > Update configurations when Search is disabled` : `Update ${preparedOption.label} in Connected accounts before connecting this source` ) }