Skip to content

Commit 7ac9270

Browse files
fix(connected-accounts): align organization settings and invitation flows
1 parent 71dda03 commit 7ac9270

55 files changed

Lines changed: 1400 additions & 1308 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/platform/connected-accounts.mdx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ Connected accounts must be enabled for your organization. Sim Cloud also require
1515

1616
For self-hosted deployments using environment-based feature flags, set `CREDENTIAL_GROUPS=true`. Availability is organization-scoped; personal workspaces cannot use an organization pool.
1717

18+
The setup instructions below describe **Settings → Connected accounts**, shown when Knowledge Member Access is disabled. When `KNOWLEDGE_MEMBER_ACCESS=true` and connected accounts is available, organization settings shows the Search **Integrations** page instead. Only the selected page is available, including through direct links. Switching pages does not remove existing connections or workspace access.
19+
1820
## Set up connected accounts
1921

20-
Open **Settings → Organization → Connected accounts**. You can also open **Connected accounts** from organization settings. Select **Set up connected accounts** if this is your first time.
22+
Open your organization’s **Settings → Connected accounts**. Select **Set up connected accounts** if this is your first time.
2123

2224
The page has three tabs:
2325

@@ -42,11 +44,13 @@ For Databricks, **Add** validates and saves the configuration before the provide
4244

4345
When editing Databricks, leaving the client secret blank preserves the saved secret. Changing the MCP URL or OAuth client requires people to reconnect.
4446

45-
Adding a provider makes account connections available. Indexable providers also show **Indexing on/off**, with the switch inside **Configure**. Indexing is a separate, optional Search capability; workflows can use connected accounts with indexing off. Managed MCP providers currently support live tool calls only and have no indexing switch.
47+
Adding a provider makes account connections available without enabling indexing. Search source setup is managed on **Settings → Integrations** when Search is enabled. Connected accounts has no indexing controls or status indicators. Managed MCP providers currently support live tool calls only.
4648

4749
### 2. Invite people
4850

49-
1. Open **People** and select **Invite people**.
51+
For Search-enabled organizations, open **Settings → Integrations → People**. Otherwise, use **Settings → Connected accounts → People**. Set up a provider for personal account connections before inviting people; approving a Search integration alone is not enough.
52+
53+
1. Select **Request connections**.
5054
2. Enter their email addresses and select **Send requests**.
5155
3. Each person opens the invitation, signs in to Sim with the verified invitation email, and authorizes the providers they want to connect.
5256
4. They select **Submit** to finish the connection form.
@@ -61,11 +65,11 @@ On first use, the invitation email must match the signed-in user's verified Sim
6165

6266
OAuth account providers also verify that the provider email matches the invitation email. Managed MCP connections are associated with the verified Sim user who completes authorization; they do not independently verify the MCP provider's account email. Keep using the invitation email when looking up those connections.
6367

64-
In **People**, use **Resend** or **Copy new link** when someone needs another invitation link. **Revoke** stops the organization from using that person's contributions. The person cannot undo an administrator's revocation by reconnecting on their own.
68+
In **People**, open a person's actions menu and select **Resend** when they need another invitation. **Revoke** stops the organization from using that person's contributions. The person cannot undo an administrator's revocation by reconnecting on their own.
6569

6670
### 3. Allow workspaces
6771

68-
Open **Workspace access**, select the workspaces that should use the pool, and select **Save access**. Only workspaces in this organization can be allowed.
72+
Open **Workspace access** and select **Add workspaces** to choose one or more workspaces. The list shows workspaces that have access; use a row's **Remove** action to withdraw it. Adding or removing a workspace applies immediately. Only workspaces in this organization can be allowed.
6973

7074
<Callout>
7175
An allowed workspace gives every authorized manual and deployed workflow in that workspace access to **every active account in the pool**. This includes scheduled, webhook, and public deployments that pass their normal workflow authorization. There is no separate workflow allowlist or restriction to the running user's own contributions.

apps/sim/app/api/organizations/[id]/connected-accounts/invitation-link/route.ts

Lines changed: 0 additions & 22 deletions
This file was deleted.

apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Metadata } from 'next'
22
import { notFound, redirect } from 'next/navigation'
33
import { getSession } from '@/lib/auth'
44
import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
5-
import { organizationRoutes } from '@/lib/navigation/paths'
5+
import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
66
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
77
import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home'
88

@@ -18,8 +18,7 @@ export default async function OrganizationChatPage({
1818
if (!session?.user?.id) notFound()
1919
const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
2020
if (!context) notFound()
21-
if (!context.searchAccess.memberScoped)
22-
redirect(organizationRoutes(organizationId).settingsSection('general'))
21+
if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH)
2322
const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id, {
2423
principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id },
2524
})

apps/sim/app/o/[organizationId]/home/page.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ describe('organization Search page gates', () => {
4747
['Search', () => OrganizationSearchPage({ params })],
4848
['chat', () => OrganizationChatPage({ params })],
4949
['organization entry', () => OrganizationPage({ params })],
50-
] as const)('redirects %s to General settings when Search is disabled', async (_name, open) => {
50+
] as const)('redirects %s to workspace settings when Search is disabled', async (_name, open) => {
5151
mocks.context.mockResolvedValue({ searchAccess: { memberScoped: false, sourceMirrored: true } })
52-
await expect(open()).rejects.toThrow('redirect:/o/org-1/settings/general')
52+
await expect(open()).rejects.toThrow('redirect:/workspace?redirect=settings')
5353
expect(mocks.context).toHaveBeenCalledWith('org-1', 'viewer')
5454
expect(mocks.chat).not.toHaveBeenCalled()
5555
})

apps/sim/app/o/[organizationId]/home/page.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Metadata } from 'next'
22
import { notFound, redirect } from 'next/navigation'
33
import { getSession } from '@/lib/auth'
4-
import { organizationRoutes } from '@/lib/navigation/paths'
4+
import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
55
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
66
import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home'
77

@@ -19,8 +19,7 @@ export default async function OrganizationHomePage({
1919
if (!session?.user?.id) notFound()
2020
const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
2121
if (!context) notFound()
22-
if (!context.searchAccess.memberScoped)
23-
redirect(organizationRoutes(organizationId).settingsSection('general'))
22+
if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH)
2423

2524
return <OrganizationHome userName={session.user.name} />
2625
}

apps/sim/app/o/[organizationId]/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { notFound, redirect } from 'next/navigation'
22
import { getSession } from '@/lib/auth'
3-
import { organizationRoutes } from '@/lib/navigation/paths'
3+
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
44
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
55

66
export default async function OrganizationPage({
@@ -14,5 +14,5 @@ export default async function OrganizationPage({
1414
const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
1515
if (!context) notFound()
1616
const routes = organizationRoutes(organizationId)
17-
redirect(context.searchAccess.memberScoped ? routes.home : routes.settingsSection('general'))
17+
redirect(context.searchAccess.memberScoped ? routes.home : WORKSPACE_SETTINGS_PATH)
1818
}

apps/sim/app/o/[organizationId]/search/page.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Metadata } from 'next'
22
import { notFound, redirect } from 'next/navigation'
33
import { getSession } from '@/lib/auth'
4-
import { organizationRoutes } from '@/lib/navigation/paths'
4+
import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
55
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
66
import { OrganizationSearch } from '@/app/o/[organizationId]/search/search'
77

@@ -17,7 +17,6 @@ export default async function OrganizationSearchPage({
1717
if (!session?.user?.id) notFound()
1818
const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
1919
if (!context) notFound()
20-
if (!context.searchAccess.memberScoped)
21-
redirect(organizationRoutes(organizationId).settingsSection('general'))
20+
if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH)
2221
return <OrganizationSearch />
2322
}

apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from '@/components/settings/navigation'
99
import { SettingsSectionProvider } from '@/components/settings/settings-panel'
1010
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
11-
import { OrganizationIntegrationsSetup } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup'
11+
import { OrganizationIntegrationsSettings } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings'
1212
import { OrganizationSearchMcp } from '@/app/o/[organizationId]/settings/components/organization-search-mcp'
1313
import { OrganizationConnectedAccounts } from '@/ee/credential-groups/components/organization-connected-accounts'
1414

@@ -60,7 +60,7 @@ export function OrganizationSettings({ section }: OrganizationSettingsProps) {
6060

6161
return (
6262
<SettingsSectionProvider section={section} meta={meta}>
63-
{section === 'integrations' && <OrganizationIntegrationsSetup />}
63+
{section === 'integrations' && <OrganizationIntegrationsSettings />}
6464
{section === 'connected-accounts' && (
6565
<OrganizationConnectedAccounts organizationId={organizationId} />
6666
)}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/** @vitest-environment jsdom */
2+
import { act } from 'react'
3+
import { toast } from '@sim/emcn'
4+
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const mocks = vi.hoisted(() => ({
9+
context: vi.fn(),
10+
accounts: vi.fn(),
11+
people: vi.fn(),
12+
invite: vi.fn(),
13+
refetch: vi.fn(),
14+
}))
15+
vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
16+
useOrganizationContext: mocks.context,
17+
}))
18+
vi.mock(
19+
'@/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup',
20+
() => ({ OrganizationIntegrationsSetup: () => <div>Provider setup</div> })
21+
)
22+
vi.mock('@/hooks/queries/organization-accounts', () => ({
23+
useOrganizationAccounts: mocks.accounts,
24+
useOrganizationAccountPeople: mocks.people,
25+
useInviteOrganizationAccountPeople: () => ({ mutateAsync: mocks.invite, reset: vi.fn() }),
26+
useResendOrganizationAccountInvitation: () => ({}),
27+
useRevokeOrganizationAccountEnrollment: () => ({}),
28+
}))
29+
30+
import { OrganizationIntegrationsSettings } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings'
31+
32+
describe('organization integration invitations', () => {
33+
let root: Root
34+
let container: HTMLDivElement
35+
36+
beforeEach(() => {
37+
vi.clearAllMocks()
38+
vi.spyOn(toast, 'success').mockReturnValue('toast-id')
39+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
40+
mocks.context.mockReturnValue({ organization: { id: 'org-a' }, viewer: { isAdmin: true } })
41+
mocks.accounts.mockReturnValue({
42+
data: { credentialGroup: { id: 'group-a' } },
43+
error: null,
44+
refetch: mocks.refetch,
45+
})
46+
mocks.people.mockReturnValue({ data: { pages: [{ enrollments: [] }] } })
47+
mocks.invite.mockResolvedValue({
48+
sentCount: 2,
49+
results: [
50+
{ email: 'one@example.com', success: true },
51+
{ email: 'two@example.com', success: true },
52+
],
53+
})
54+
container = document.createElement('div')
55+
document.body.appendChild(container)
56+
root = createRoot(container)
57+
})
58+
59+
afterEach(async () => {
60+
await act(async () => root.unmount())
61+
container.remove()
62+
vi.restoreAllMocks()
63+
vi.unstubAllGlobals()
64+
})
65+
66+
async function render(searchParams = '') {
67+
await act(async () =>
68+
root.render(
69+
<NuqsTestingAdapter hasMemory searchParams={searchParams}>
70+
<OrganizationIntegrationsSettings />
71+
</NuqsTestingAdapter>
72+
)
73+
)
74+
}
75+
76+
async function click(label: string) {
77+
const button = Array.from(document.querySelectorAll('button')).find(
78+
(element) => element.textContent === label
79+
)
80+
if (!button) throw new Error(`Missing ${label} button`)
81+
await act(async () => button.click())
82+
}
83+
84+
it('keeps provider setup as the default and sends manual invitations from People to this org', async () => {
85+
await render()
86+
expect(container.textContent).toContain('Provider setup')
87+
expect(mocks.accounts).toHaveBeenLastCalledWith(undefined)
88+
expect(mocks.people).not.toHaveBeenCalled()
89+
90+
await click('People')
91+
expect(container.textContent).not.toContain('Provider setup')
92+
expect(mocks.accounts).toHaveBeenLastCalledWith('org-a')
93+
expect(mocks.people).toHaveBeenLastCalledWith('org-a')
94+
expect(container.querySelector('[aria-label="Search people"]')).not.toBeNull()
95+
expect(mocks.invite).not.toHaveBeenCalled()
96+
97+
await click('Request connections')
98+
const input = document.querySelector<HTMLInputElement>('input[placeholder="Enter emails"]')
99+
if (!input) throw new Error('Missing invitation email input')
100+
const paste = new Event('paste', { bubbles: true, cancelable: true })
101+
Object.defineProperty(paste, 'clipboardData', {
102+
value: { getData: () => 'one@example.com two@example.com' },
103+
})
104+
await act(async () => input.dispatchEvent(paste))
105+
await click('Send requests')
106+
expect(mocks.invite).toHaveBeenCalledExactlyOnceWith({
107+
organizationId: 'org-a',
108+
emails: ['one@example.com', 'two@example.com'],
109+
})
110+
expect(document.querySelector('[role="dialog"]')).toBeNull()
111+
})
112+
113+
it('opens People directly from the saved URL', async () => {
114+
await render('?tab=people')
115+
expect(container.textContent).toContain('Request connections')
116+
expect(container.textContent).not.toContain('Provider setup')
117+
expect(mocks.people).toHaveBeenLastCalledWith('org-a')
118+
})
119+
120+
it('sends an org without a credential group back to provider setup before invitations', async () => {
121+
mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, error: null })
122+
await render('?tab=people')
123+
expect(container.textContent).toContain('before inviting people')
124+
expect(mocks.people).not.toHaveBeenCalled()
125+
expect(container.textContent).not.toContain('Request connections')
126+
await click('Set up providers')
127+
expect(container.textContent).toContain('Provider setup')
128+
expect(mocks.invite).not.toHaveBeenCalled()
129+
})
130+
131+
it('surfaces account lookup errors instead of treating them as missing setup', async () => {
132+
mocks.accounts.mockReturnValue({
133+
error: new Error('Account access denied'),
134+
refetch: mocks.refetch,
135+
})
136+
await render('?tab=people')
137+
expect(container.textContent).toContain('Account access denied')
138+
expect(container.textContent).not.toContain('Set up providers')
139+
expect(mocks.people).not.toHaveBeenCalled()
140+
await click('Try again')
141+
expect(mocks.refetch).toHaveBeenCalledOnce()
142+
})
143+
144+
it('does not load admin account data or expose invitations to an ordinary member', async () => {
145+
mocks.context.mockReturnValue({ organization: { id: 'org-a' }, viewer: { isAdmin: false } })
146+
await render('?tab=people')
147+
expect(container.textContent).toBe('')
148+
expect(mocks.accounts).toHaveBeenLastCalledWith(undefined)
149+
expect(mocks.people).not.toHaveBeenCalled()
150+
expect(mocks.invite).not.toHaveBeenCalled()
151+
})
152+
})
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
'use client'
2+
3+
import { Chip, ChipSwitch } from '@sim/emcn'
4+
import { useQueryState } from 'nuqs'
5+
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
6+
import { OrganizationIntegrationsSetup } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup'
7+
import { organizationIntegrationsTabParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params'
8+
import {
9+
SettingsEmptyState,
10+
SettingsQueryErrorState,
11+
} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
12+
import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people'
13+
import { useOrganizationAccounts } from '@/hooks/queries/organization-accounts'
14+
15+
export function OrganizationIntegrationsSettings() {
16+
const { organization, viewer } = useOrganizationContext()
17+
const [tab, setTab] = useQueryState(
18+
organizationIntegrationsTabParam.key,
19+
organizationIntegrationsTabParam.parser
20+
)
21+
const accounts = useOrganizationAccounts(
22+
viewer.isAdmin && tab === 'people' ? organization.id : undefined
23+
)
24+
if (!viewer.isAdmin) return null
25+
26+
return (
27+
<div className='flex flex-col gap-6'>
28+
<div>
29+
<ChipSwitch
30+
aria-label='Integration settings'
31+
value={tab}
32+
onChange={(value) => void setTab(value)}
33+
options={[
34+
{ value: 'providers', label: 'Providers' },
35+
{ value: 'people', label: 'People' },
36+
]}
37+
/>
38+
</div>
39+
{tab === 'providers' && <OrganizationIntegrationsSetup />}
40+
{tab === 'people' &&
41+
(accounts.error ? (
42+
<SettingsQueryErrorState
43+
error={accounts.error}
44+
fallback='Could not load connected accounts'
45+
isRetrying={accounts.isFetching}
46+
onRetry={() => void accounts.refetch()}
47+
variant='inline'
48+
/>
49+
) : !accounts.data ? (
50+
<SettingsEmptyState variant='inline'>Loading connected accounts…</SettingsEmptyState>
51+
) : !accounts.data.credentialGroup ? (
52+
<div className='flex flex-col items-start gap-4'>
53+
<SettingsEmptyState variant='inline'>
54+
Set up a provider for personal account connections before inviting people.
55+
</SettingsEmptyState>
56+
<Chip onClick={() => void setTab('providers')}>Set up providers</Chip>
57+
</div>
58+
) : (
59+
<OrganizationAccountPeople key={organization.id} organizationId={organization.id} />
60+
))}
61+
</div>
62+
)
63+
}

0 commit comments

Comments
 (0)