Skip to content

Commit c59e1da

Browse files
committed
fix(settings): let people revoke the accounts they connected for an organization, even after leaving
1 parent 395a394 commit c59e1da

7 files changed

Lines changed: 256 additions & 11 deletions

File tree

‎apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export function AccountActionsMenu({
4343
permissionAccountIds,
4444
reconnectAccountIds,
4545
}: AccountActionsMenuProps) {
46-
const disconnect = useDisconnectPersonalOrganizationAccount(organizationId)
46+
const disconnect = useDisconnectPersonalOrganizationAccount()
4747
const [selectedId, setSelectedId] = useState<string | null>(null)
4848
const selected = accounts.find((account) => account.credentialId === selectedId)
4949
const permissionAccounts = accounts.filter((account) =>
@@ -118,7 +118,10 @@ export function AccountActionsMenu({
118118
disabled: disconnect.isPending,
119119
onClick: () => {
120120
if (!selected || disconnect.isPending) return
121-
disconnect.mutate(selected.credentialId, { onSuccess: () => setSelectedId(null) })
121+
disconnect.mutate(
122+
{ credentialId: selected.credentialId, organizationId },
123+
{ onSuccess: () => setSelectedId(null) }
124+
)
122125
},
123126
}}
124127
>

‎apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function AccountConnectionActions({
3030
canConnect,
3131
connectionPending,
3232
}: AccountConnectionActionsProps) {
33-
const disconnect = useDisconnectPersonalOrganizationAccount(organizationId)
33+
const disconnect = useDisconnectPersonalOrganizationAccount()
3434
const [confirming, setConfirming] = useState(false)
3535
const pending = connectionPending || disconnect.isPending
3636
const actions: SettingsAction[] = []
@@ -79,7 +79,10 @@ export function AccountConnectionActions({
7979
pending: disconnect.isPending,
8080
disabled: disconnect.isPending,
8181
onClick: () =>
82-
disconnect.mutate(account.credentialId, { onSuccess: () => setConfirming(false) }),
82+
disconnect.mutate(
83+
{ credentialId: account.credentialId, organizationId },
84+
{ onSuccess: () => setConfirming(false) }
85+
),
8386
}}
8487
>
8588
<ChipModalError>{disconnect.error?.message}</ChipModalError>

‎apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ const AuthorizedApps = dynamic(
6464
{ loading: () => null }
6565
)
6666

67+
const OrganizationAccounts = dynamic(
68+
() =>
69+
import(
70+
'@/app/workspace/[workspaceId]/settings/components/organization-accounts/organization-accounts'
71+
).then((module) => module.OrganizationAccounts),
72+
{ loading: () => null }
73+
)
74+
6775
const logger = createLogger('General')
6876

6977
/** Human-friendly timezone options for the picker, common zones first. */
@@ -289,6 +297,10 @@ export function General() {
289297
return <AuthorizedApps onBack={() => setView(null, { history: 'replace' })} />
290298
}
291299

300+
if (view === 'organization-accounts' && !isAuthDisabled) {
301+
return <OrganizationAccounts onBack={() => setView(null, { history: 'replace' })} />
302+
}
303+
292304
const actions: SettingsAction[] = [
293305
...(hosted
294306
? [
@@ -619,6 +631,10 @@ export function General() {
619631
<Label>Authorized apps</Label>
620632
<Chip onClick={() => setView('authorized-apps')}>Manage</Chip>
621633
</div>
634+
<div className='flex items-center justify-between'>
635+
<Label>Connected organization accounts</Label>
636+
<Chip onClick={() => setView('organization-accounts')}>Manage</Chip>
637+
</div>
622638
<div className='flex items-center justify-between'>
623639
<Label>Delete account</Label>
624640
<Chip onClick={() => setShowDeleteAccountModal(true)}>Delete</Chip>

‎apps/sim/app/workspace/[workspaceId]/settings/components/general/search-params.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { parseAsStringLiteral } from 'nuqs/server'
66
*/
77
export const generalViewParam = {
88
key: 'view',
9-
parser: parseAsStringLiteral(['privacy', 'authorized-apps'] as const),
9+
parser: parseAsStringLiteral(['privacy', 'authorized-apps', 'organization-accounts'] as const),
1010
} as const
1111

1212
/** Opening the sub-view is a destination — Back should return to General. */
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { Chip, ChipConfirmModal, toast } from '@sim/emcn'
5+
import { ArrowLeft } from '@sim/emcn/icons'
6+
import { getErrorMessage } from '@sim/utils/errors'
7+
import type { PersonalOrganizationAccount } from '@/lib/api/contracts/organization-accounts'
8+
import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons'
9+
import {
10+
getManagedMcpConnector,
11+
isManagedMcpConnectorId,
12+
} from '@/lib/credential-groups/managed-mcp-connectors'
13+
import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
14+
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
15+
import {
16+
SettingsEmptyState,
17+
SettingsQueryErrorState,
18+
} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
19+
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
20+
import {
21+
RESOURCE_LIST_STACK,
22+
SettingsResourceRow,
23+
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
24+
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
25+
import {
26+
useDisconnectPersonalOrganizationAccount,
27+
usePersonalOrganizationAccounts,
28+
} from '@/hooks/queries/organization-accounts'
29+
30+
const STATUS_LABELS = {
31+
active: null,
32+
needs_reauth: 'Reconnect needed',
33+
revoked: 'Disconnected',
34+
} as const satisfies Record<PersonalOrganizationAccount['status'], string | null>
35+
36+
const LOAD_ERROR = 'Failed to load connected accounts'
37+
38+
interface OrganizationAccountsProps {
39+
onBack: () => void
40+
}
41+
42+
function accountService(account: PersonalOrganizationAccount) {
43+
if (account.kind === 'mcp') {
44+
if (!isManagedMcpConnectorId(account.providerId)) return null
45+
return {
46+
name: getManagedMcpConnector(account.providerId).name,
47+
icon: getManagedMcpConnectorIcon(account.providerId),
48+
}
49+
}
50+
return getServiceConfigByProviderId(account.providerId)
51+
}
52+
53+
function groupByOrganization(accounts: PersonalOrganizationAccount[]) {
54+
const groups = new Map<string, { name: string; accounts: PersonalOrganizationAccount[] }>()
55+
for (const account of accounts) {
56+
const group = groups.get(account.organizationId)
57+
if (group) group.accounts.push(account)
58+
else groups.set(account.organizationId, { name: account.organizationName, accounts: [account] })
59+
}
60+
return [...groups.entries()].sort(([, left], [, right]) => left.name.localeCompare(right.name))
61+
}
62+
63+
/**
64+
* The accounts this person connected for an organization. Membership does not
65+
* gate it: someone who left an organization still disconnects their own grants
66+
* here, because the organization's Integrations page is closed to them.
67+
*/
68+
export function OrganizationAccounts({ onBack }: OrganizationAccountsProps) {
69+
const accounts = usePersonalOrganizationAccounts()
70+
const disconnect = useDisconnectPersonalOrganizationAccount()
71+
const [pendingDisconnectId, setPendingDisconnectId] = useState<string | null>(null)
72+
73+
const list = accounts.data?.pages.flatMap((page) => page.accounts) ?? []
74+
const pendingDisconnect =
75+
list.find((account) => account.credentialId === pendingDisconnectId) ?? null
76+
77+
const confirmDisconnect = () => {
78+
if (!pendingDisconnect) return
79+
disconnect.mutate(
80+
{
81+
credentialId: pendingDisconnect.credentialId,
82+
organizationId: pendingDisconnect.organizationId,
83+
},
84+
{
85+
onSuccess: () => toast.success(`Disconnected ${pendingDisconnect.displayName}`),
86+
onError: (error) => toast.error(getErrorMessage(error, 'Failed to disconnect account')),
87+
onSettled: () => setPendingDisconnectId(null),
88+
}
89+
)
90+
}
91+
92+
return (
93+
<>
94+
<SettingsPanel
95+
back={{ text: 'General', icon: ArrowLeft, onSelect: onBack }}
96+
title='Connected organization accounts'
97+
description='Disconnect accounts you connected for an organization, including ones you have left.'
98+
>
99+
{accounts.isError && accounts.data === undefined ? (
100+
<SettingsQueryErrorState
101+
error={accounts.error}
102+
fallback={LOAD_ERROR}
103+
isRetrying={accounts.isFetching}
104+
onRetry={() => accounts.refetch()}
105+
/>
106+
) : accounts.isPending ? (
107+
<SettingsEmptyState>Loading connected accounts…</SettingsEmptyState>
108+
) : list.length === 0 ? (
109+
<SettingsEmptyState>No accounts connected for an organization</SettingsEmptyState>
110+
) : (
111+
<>
112+
{groupByOrganization(list).map(([organizationId, group]) => (
113+
<SettingsSection key={organizationId} label={group.name}>
114+
<div className={RESOURCE_LIST_STACK}>
115+
{group.accounts.map((account) => {
116+
const service = accountService(account)
117+
const statusLabel = STATUS_LABELS[account.status]
118+
return (
119+
<SettingsResourceRow
120+
key={account.credentialId}
121+
icon={service ? <service.icon /> : undefined}
122+
title={account.displayName}
123+
description={service?.name ?? account.providerId}
124+
badge={
125+
statusLabel && (
126+
<span className='whitespace-nowrap text-[var(--text-muted)] text-caption'>
127+
{statusLabel}
128+
</span>
129+
)
130+
}
131+
trailing={
132+
account.status !== 'revoked' && (
133+
<RowActionsMenu
134+
label='Connected account actions'
135+
actions={[
136+
{
137+
label: 'Disconnect',
138+
destructive: true,
139+
onSelect: () => setPendingDisconnectId(account.credentialId),
140+
},
141+
]}
142+
/>
143+
)
144+
}
145+
/>
146+
)
147+
})}
148+
</div>
149+
</SettingsSection>
150+
))}
151+
{accounts.isError && (
152+
<SettingsQueryErrorState
153+
error={accounts.error}
154+
fallback={LOAD_ERROR}
155+
isRetrying={accounts.isFetching}
156+
onRetry={() =>
157+
accounts.isFetchNextPageError ? accounts.fetchNextPage() : accounts.refetch()
158+
}
159+
variant='inline'
160+
/>
161+
)}
162+
{accounts.hasNextPage && !accounts.isError && (
163+
<Chip
164+
fullWidth
165+
disabled={accounts.isFetching}
166+
onClick={() => accounts.fetchNextPage()}
167+
>
168+
{accounts.isFetchingNextPage ? 'Loading…' : 'Load more'}
169+
</Chip>
170+
)}
171+
</>
172+
)}
173+
</SettingsPanel>
174+
175+
<ChipConfirmModal
176+
open={pendingDisconnect !== null}
177+
onOpenChange={(open) => {
178+
if (!open && !disconnect.isPending) setPendingDisconnectId(null)
179+
}}
180+
srTitle='Disconnect account'
181+
title='Disconnect account'
182+
text={[
183+
'Disconnecting ',
184+
{ text: pendingDisconnect?.displayName ?? 'this account', bold: true },
185+
' removes it from ',
186+
{ text: pendingDisconnect?.organizationName ?? 'the organization', bold: true },
187+
'. ',
188+
{ text: 'Workflows using this account will lose access.', error: true },
189+
]}
190+
confirm={{
191+
label: 'Disconnect',
192+
onClick: confirmDisconnect,
193+
pending: disconnect.isPending,
194+
pendingLabel: 'Disconnecting…',
195+
}}
196+
/>
197+
</>
198+
)
199+
}

‎apps/sim/hooks/queries/organization-accounts.ts‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
type InviteOrganizationAccountPeopleBody,
2929
inviteOrganizationAccountPeopleContract,
3030
listOrganizationAccountPeopleContract,
31+
listPersonalOrganizationAccountsContract,
3132
type OrganizationAccountPeopleQuery,
3233
type RemoveOrganizationAccountMcpProviderParams,
3334
type ResendOrganizationAccountInvitationQuery,
@@ -109,16 +110,39 @@ export function useReconnectPersonalOrganizationAccount() {
109110
}))
110111
}
111112

112-
/** Disconnects an owned grant; indexing and source setup do not gate this operation. */
113-
export function useDisconnectPersonalOrganizationAccount(organizationId: string) {
113+
/**
114+
* The accounts the viewer connected for any organization, including ones they
115+
* have since left — the only place a former member can still see their grants.
116+
*/
117+
export function usePersonalOrganizationAccounts() {
118+
return useInfiniteQuery({
119+
queryKey: organizationAccountsKeys.personal(),
120+
staleTime: ORGANIZATION_ACCOUNTS_STALE_TIME,
121+
initialPageParam: undefined as string | undefined,
122+
queryFn: ({ signal, pageParam }) =>
123+
requestJson(listPersonalOrganizationAccountsContract, {
124+
query: { cursor: pageParam },
125+
signal,
126+
}),
127+
getNextPageParam: (page) => page.nextCursor ?? undefined,
128+
})
129+
}
130+
131+
interface DisconnectPersonalOrganizationAccountVariables {
132+
credentialId: string
133+
organizationId: string
134+
}
135+
136+
/** Disconnects an owned grant; indexing, source setup, and membership do not gate this operation. */
137+
export function useDisconnectPersonalOrganizationAccount() {
114138
const queryClient = useQueryClient()
115139
const router = useRouter()
116140
return useMutation({
117-
mutationFn: (credentialId: string) =>
141+
mutationFn: ({ credentialId }: DisconnectPersonalOrganizationAccountVariables) =>
118142
requestJson(disconnectPersonalOrganizationAccountContract, {
119143
params: { credentialId },
120144
}),
121-
onSuccess: async () => {
145+
onSuccess: async (_data, { organizationId }) => {
122146
await Promise.all([
123147
resetOrganizationSearchAccess(queryClient, organizationId),
124148
queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }),
@@ -127,6 +151,7 @@ export function useDisconnectPersonalOrganizationAccount(organizationId: string)
127151
queryClient.invalidateQueries({
128152
queryKey: organizationAccountsKeys.detail(organizationId),
129153
}),
154+
queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }),
130155
])
131156
router.refresh()
132157
},
@@ -136,6 +161,7 @@ export function useDisconnectPersonalOrganizationAccount(organizationId: string)
136161
export const organizationAccountsKeys = {
137162
all: ['organization-accounts'] as const,
138163
connection: () => [...organizationAccountsKeys.all, 'connection'] as const,
164+
personal: () => [...organizationAccountsKeys.all, 'personal'] as const,
139165
workspaces: () => [...organizationAccountsKeys.all, 'workspace'] as const,
140166
workspace: (workspaceId?: string) =>
141167
[...organizationAccountsKeys.workspaces(), workspaceId ?? ''] as const,

‎scripts/check-unused-exports.baseline.json‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1705,7 +1705,6 @@
17051705
"apps/sim/lib/api/contracts/oauth-provider.ts#RegisterOAuthClientBody",
17061706
"apps/sim/lib/api/contracts/oauth-provider.ts#RegisterOAuthClientResponse",
17071707
"apps/sim/lib/api/contracts/organization-accounts.ts#OrganizationAccountPeoplePage",
1708-
"apps/sim/lib/api/contracts/organization-accounts.ts#PersonalOrganizationAccount",
17091708
"apps/sim/lib/api/contracts/organization-accounts.ts#ReconnectPersonalOrganizationAccountQuery",
17101709
"apps/sim/lib/api/contracts/organization-accounts.ts#WorkspaceOrganizationAccounts",
17111710
"apps/sim/lib/api/contracts/organization-accounts.ts#organizationAccountConnectionResponseSchema",
@@ -4257,7 +4256,6 @@
42574256
"apps/sim/lib/credential-groups/enrollments.ts#getPublicCredentialGroupEnrollment",
42584257
"apps/sim/lib/credential-groups/limits.ts#ORGANIZATION_ACCOUNT_INDEXING_SOURCE_LIMIT",
42594258
"apps/sim/lib/credential-groups/managed-mcp-connector-icons.ts#MANAGED_MCP_CONNECTOR_ICONS",
4260-
"apps/sim/lib/credential-groups/managed-mcp-connectors.ts#isManagedMcpConnectorId",
42614259
"apps/sim/lib/credential-groups/provider-adapter.ts#PreparedCredentialGroupAuthorization",
42624260
"apps/sim/lib/credential-groups/provider-configuration.ts#SlackAppCredentialGroupConfiguration",
42634261
"apps/sim/lib/credential-groups/slack-managed-users.ts#getSlackManagedUsersRedirectUri",

0 commit comments

Comments
 (0)