diff --git a/apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx b/apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx index 7defbf567e0..55ae8cbef3e 100644 --- a/apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx @@ -43,7 +43,7 @@ export function AccountActionsMenu({ permissionAccountIds, reconnectAccountIds, }: AccountActionsMenuProps) { - const disconnect = useDisconnectPersonalOrganizationAccount(organizationId) + const disconnect = useDisconnectPersonalOrganizationAccount() const [selectedId, setSelectedId] = useState(null) const selected = accounts.find((account) => account.credentialId === selectedId) const permissionAccounts = accounts.filter((account) => @@ -118,7 +118,10 @@ export function AccountActionsMenu({ disabled: disconnect.isPending, onClick: () => { if (!selected || disconnect.isPending) return - disconnect.mutate(selected.credentialId, { onSuccess: () => setSelectedId(null) }) + disconnect.mutate( + { credentialId: selected.credentialId, organizationId }, + { onSuccess: () => setSelectedId(null) } + ) }, }} > diff --git a/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx b/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx index 74d0d8d798d..f886a70644a 100644 --- a/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx @@ -30,7 +30,7 @@ export function AccountConnectionActions({ canConnect, connectionPending, }: AccountConnectionActionsProps) { - const disconnect = useDisconnectPersonalOrganizationAccount(organizationId) + const disconnect = useDisconnectPersonalOrganizationAccount() const [confirming, setConfirming] = useState(false) const pending = connectionPending || disconnect.isPending const actions: SettingsAction[] = [] @@ -79,7 +79,10 @@ export function AccountConnectionActions({ pending: disconnect.isPending, disabled: disconnect.isPending, onClick: () => - disconnect.mutate(account.credentialId, { onSuccess: () => setConfirming(false) }), + disconnect.mutate( + { credentialId: account.credentialId, organizationId }, + { onSuccess: () => setConfirming(false) } + ), }} > {disconnect.error?.message} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index 22511883b7a..958fcacbf12 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -64,6 +64,14 @@ const AuthorizedApps = dynamic( { loading: () => null } ) +const OrganizationAccounts = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/settings/components/organization-accounts/organization-accounts' + ).then((module) => module.OrganizationAccounts), + { loading: () => null } +) + const logger = createLogger('General') /** Human-friendly timezone options for the picker, common zones first. */ @@ -289,6 +297,10 @@ export function General() { return setView(null, { history: 'replace' })} /> } + if (view === 'organization-accounts' && !isAuthDisabled) { + return setView(null, { history: 'replace' })} /> + } + const actions: SettingsAction[] = [ ...(hosted ? [ @@ -619,6 +631,10 @@ export function General() { setView('authorized-apps')}>Manage +
+ + setView('organization-accounts')}>Manage +
setShowDeleteAccountModal(true)}>Delete diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/general/search-params.ts index ada30101961..ff4e153ab58 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/search-params.ts @@ -6,7 +6,7 @@ import { parseAsStringLiteral } from 'nuqs/server' */ export const generalViewParam = { key: 'view', - parser: parseAsStringLiteral(['privacy', 'authorized-apps'] as const), + parser: parseAsStringLiteral(['privacy', 'authorized-apps', 'organization-accounts'] as const), } as const /** Opening the sub-view is a destination — Back should return to General. */ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-accounts/organization-accounts.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-accounts/organization-accounts.tsx new file mode 100644 index 00000000000..6988dfe45ad --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-accounts/organization-accounts.tsx @@ -0,0 +1,199 @@ +'use client' + +import { useState } from 'react' +import { Chip, ChipConfirmModal, toast } from '@sim/emcn' +import { ArrowLeft } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import type { PersonalOrganizationAccount } from '@/lib/api/contracts/organization-accounts' +import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' +import { + getManagedMcpConnector, + isManagedMcpConnectorId, +} from '@/lib/credential-groups/managed-mcp-connectors' +import { getServiceConfigByProviderId } from '@/lib/oauth/utils' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} 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 { + useDisconnectPersonalOrganizationAccount, + usePersonalOrganizationAccounts, +} from '@/hooks/queries/organization-accounts' + +const STATUS_LABELS = { + active: null, + needs_reauth: 'Reconnect needed', + revoked: 'Disconnected', +} as const satisfies Record + +const LOAD_ERROR = 'Failed to load connected accounts' + +interface OrganizationAccountsProps { + onBack: () => void +} + +function accountService(account: PersonalOrganizationAccount) { + if (account.kind === 'mcp') { + if (!isManagedMcpConnectorId(account.providerId)) return null + return { + name: getManagedMcpConnector(account.providerId).name, + icon: getManagedMcpConnectorIcon(account.providerId), + } + } + return getServiceConfigByProviderId(account.providerId) +} + +function groupByOrganization(accounts: PersonalOrganizationAccount[]) { + const groups = new Map() + for (const account of accounts) { + const group = groups.get(account.organizationId) + if (group) group.accounts.push(account) + else groups.set(account.organizationId, { name: account.organizationName, accounts: [account] }) + } + return [...groups.entries()].sort(([, left], [, right]) => left.name.localeCompare(right.name)) +} + +/** + * The accounts this person connected for an organization. Membership does not + * gate it: someone who left an organization still disconnects their own grants + * here, because the organization's Integrations page is closed to them. + */ +export function OrganizationAccounts({ onBack }: OrganizationAccountsProps) { + const accounts = usePersonalOrganizationAccounts() + const disconnect = useDisconnectPersonalOrganizationAccount() + const [pendingDisconnectId, setPendingDisconnectId] = useState(null) + + const list = accounts.data?.pages.flatMap((page) => page.accounts) ?? [] + const pendingDisconnect = + list.find((account) => account.credentialId === pendingDisconnectId) ?? null + + const confirmDisconnect = () => { + if (!pendingDisconnect) return + disconnect.mutate( + { + credentialId: pendingDisconnect.credentialId, + organizationId: pendingDisconnect.organizationId, + }, + { + onSuccess: () => toast.success(`Disconnected ${pendingDisconnect.displayName}`), + onError: (error) => toast.error(getErrorMessage(error, 'Failed to disconnect account')), + onSettled: () => setPendingDisconnectId(null), + } + ) + } + + return ( + <> + + {accounts.isError && accounts.data === undefined ? ( + accounts.refetch()} + /> + ) : accounts.isPending ? ( + Loading connected accounts… + ) : list.length === 0 ? ( + No accounts connected for an organization + ) : ( + <> + {groupByOrganization(list).map(([organizationId, group]) => ( + +
+ {group.accounts.map((account) => { + const service = accountService(account) + const statusLabel = STATUS_LABELS[account.status] + return ( + : undefined} + title={account.displayName} + description={service?.name ?? account.providerId} + badge={ + statusLabel && ( + + {statusLabel} + + ) + } + trailing={ + account.status !== 'revoked' && ( + setPendingDisconnectId(account.credentialId), + }, + ]} + /> + ) + } + /> + ) + })} +
+
+ ))} + {accounts.isError && ( + + accounts.isFetchNextPageError ? accounts.fetchNextPage() : accounts.refetch() + } + variant='inline' + /> + )} + {accounts.hasNextPage && !accounts.isError && ( + accounts.fetchNextPage()} + > + {accounts.isFetchingNextPage ? 'Loading…' : 'Load more'} + + )} + + )} +
+ + { + if (!open && !disconnect.isPending) setPendingDisconnectId(null) + }} + srTitle='Disconnect account' + title='Disconnect account' + text={[ + 'Disconnecting ', + { text: pendingDisconnect?.displayName ?? 'this account', bold: true }, + ' removes it from ', + { text: pendingDisconnect?.organizationName ?? 'the organization', bold: true }, + '. ', + { text: 'Workflows using this account will lose access.', error: true }, + ]} + confirm={{ + label: 'Disconnect', + onClick: confirmDisconnect, + pending: disconnect.isPending, + pendingLabel: 'Disconnecting…', + }} + /> + + ) +} diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index 54765a07de1..b514f67f515 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -28,6 +28,7 @@ import { type InviteOrganizationAccountPeopleBody, inviteOrganizationAccountPeopleContract, listOrganizationAccountPeopleContract, + listPersonalOrganizationAccountsContract, type OrganizationAccountPeopleQuery, type RemoveOrganizationAccountMcpProviderParams, type ResendOrganizationAccountInvitationQuery, @@ -109,16 +110,39 @@ export function useReconnectPersonalOrganizationAccount() { })) } -/** Disconnects an owned grant; indexing and source setup do not gate this operation. */ -export function useDisconnectPersonalOrganizationAccount(organizationId: string) { +/** + * The accounts the viewer connected for any organization, including ones they + * have since left — the only place a former member can still see their grants. + */ +export function usePersonalOrganizationAccounts() { + return useInfiniteQuery({ + queryKey: organizationAccountsKeys.personal(), + staleTime: ORGANIZATION_ACCOUNTS_STALE_TIME, + initialPageParam: undefined as string | undefined, + queryFn: ({ signal, pageParam }) => + requestJson(listPersonalOrganizationAccountsContract, { + query: { cursor: pageParam }, + signal, + }), + getNextPageParam: (page) => page.nextCursor ?? undefined, + }) +} + +interface DisconnectPersonalOrganizationAccountVariables { + credentialId: string + organizationId: string +} + +/** Disconnects an owned grant; indexing, source setup, and membership do not gate this operation. */ +export function useDisconnectPersonalOrganizationAccount() { const queryClient = useQueryClient() const router = useRouter() return useMutation({ - mutationFn: (credentialId: string) => + mutationFn: ({ credentialId }: DisconnectPersonalOrganizationAccountVariables) => requestJson(disconnectPersonalOrganizationAccountContract, { params: { credentialId }, }), - onSuccess: async () => { + onSuccess: async (_data, { organizationId }) => { await Promise.all([ resetOrganizationSearchAccess(queryClient, organizationId), queryClient.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), @@ -127,6 +151,7 @@ export function useDisconnectPersonalOrganizationAccount(organizationId: string) queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.detail(organizationId), }), + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), ]) router.refresh() }, @@ -136,6 +161,7 @@ export function useDisconnectPersonalOrganizationAccount(organizationId: string) export const organizationAccountsKeys = { all: ['organization-accounts'] as const, connection: () => [...organizationAccountsKeys.all, 'connection'] as const, + personal: () => [...organizationAccountsKeys.all, 'personal'] as const, workspaces: () => [...organizationAccountsKeys.all, 'workspace'] as const, workspace: (workspaceId?: string) => [...organizationAccountsKeys.workspaces(), workspaceId ?? ''] as const, diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index e4448b682b2..d8b634cb2cb 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -1705,7 +1705,6 @@ "apps/sim/lib/api/contracts/oauth-provider.ts#RegisterOAuthClientBody", "apps/sim/lib/api/contracts/oauth-provider.ts#RegisterOAuthClientResponse", "apps/sim/lib/api/contracts/organization-accounts.ts#OrganizationAccountPeoplePage", - "apps/sim/lib/api/contracts/organization-accounts.ts#PersonalOrganizationAccount", "apps/sim/lib/api/contracts/organization-accounts.ts#ReconnectPersonalOrganizationAccountQuery", "apps/sim/lib/api/contracts/organization-accounts.ts#WorkspaceOrganizationAccounts", "apps/sim/lib/api/contracts/organization-accounts.ts#organizationAccountConnectionResponseSchema", @@ -4257,7 +4256,6 @@ "apps/sim/lib/credential-groups/enrollments.ts#getPublicCredentialGroupEnrollment", "apps/sim/lib/credential-groups/limits.ts#ORGANIZATION_ACCOUNT_INDEXING_SOURCE_LIMIT", "apps/sim/lib/credential-groups/managed-mcp-connector-icons.ts#MANAGED_MCP_CONNECTOR_ICONS", - "apps/sim/lib/credential-groups/managed-mcp-connectors.ts#isManagedMcpConnectorId", "apps/sim/lib/credential-groups/provider-adapter.ts#PreparedCredentialGroupAuthorization", "apps/sim/lib/credential-groups/provider-configuration.ts#SlackAppCredentialGroupConfiguration", "apps/sim/lib/credential-groups/slack-managed-users.ts#getSlackManagedUsersRedirectUri",