Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function AccountActionsMenu({
permissionAccountIds,
reconnectAccountIds,
}: AccountActionsMenuProps) {
const disconnect = useDisconnectPersonalOrganizationAccount(organizationId)
const disconnect = useDisconnectPersonalOrganizationAccount()
const [selectedId, setSelectedId] = useState<string | null>(null)
const selected = accounts.find((account) => account.credentialId === selectedId)
const permissionAccounts = accounts.filter((account) =>
Expand Down Expand Up @@ -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) }
)
},
}}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []
Expand Down Expand Up @@ -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) }
),
}}
>
<ChipModalError>{disconnect.error?.message}</ChipModalError>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -289,6 +297,10 @@ export function General() {
return <AuthorizedApps onBack={() => setView(null, { history: 'replace' })} />
}

if (view === 'organization-accounts' && !isAuthDisabled) {
return <OrganizationAccounts onBack={() => setView(null, { history: 'replace' })} />
}

const actions: SettingsAction[] = [
...(hosted
? [
Expand Down Expand Up @@ -619,6 +631,10 @@ export function General() {
<Label>Authorized apps</Label>
<Chip onClick={() => setView('authorized-apps')}>Manage</Chip>
</div>
<div className='flex items-center justify-between'>
<Label>Connected organization accounts</Label>
<Chip onClick={() => setView('organization-accounts')}>Manage</Chip>
</div>
<div className='flex items-center justify-between'>
<Label>Delete account</Label>
<Chip onClick={() => setShowDeleteAccountModal(true)}>Delete</Chip>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PersonalOrganizationAccount['status'], string | null>

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<string, { name: string; accounts: PersonalOrganizationAccount[] }>()
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<string | null>(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 (
<>
<SettingsPanel
back={{ text: 'General', icon: ArrowLeft, onSelect: onBack }}
title='Connected organization accounts'
description='Disconnect accounts you connected for an organization, including ones you have left.'
>
{accounts.isError && accounts.data === undefined ? (
<SettingsQueryErrorState
error={accounts.error}
fallback={LOAD_ERROR}
isRetrying={accounts.isFetching}
onRetry={() => accounts.refetch()}
/>
) : accounts.isPending ? (
<SettingsEmptyState>Loading connected accounts…</SettingsEmptyState>
) : list.length === 0 ? (
<SettingsEmptyState>No accounts connected for an organization</SettingsEmptyState>
) : (
<>
{groupByOrganization(list).map(([organizationId, group]) => (
<SettingsSection key={organizationId} label={group.name}>
<div className={RESOURCE_LIST_STACK}>
{group.accounts.map((account) => {
const service = accountService(account)
const statusLabel = STATUS_LABELS[account.status]
return (
<SettingsResourceRow
key={account.credentialId}
icon={service ? <service.icon /> : undefined}
title={account.displayName}
description={service?.name ?? account.providerId}
badge={
statusLabel && (
<span className='whitespace-nowrap text-[var(--text-muted)] text-caption'>
{statusLabel}
</span>
)
}
trailing={
account.status !== 'revoked' && (
<RowActionsMenu
label='Connected account actions'
actions={[
{
label: 'Disconnect',
destructive: true,
onSelect: () => setPendingDisconnectId(account.credentialId),
},
]}
/>
)
}
/>
)
})}
</div>
</SettingsSection>
))}
{accounts.isError && (
<SettingsQueryErrorState
error={accounts.error}
fallback={LOAD_ERROR}
isRetrying={accounts.isFetching}
onRetry={() =>
accounts.isFetchNextPageError ? accounts.fetchNextPage() : accounts.refetch()
}
variant='inline'
/>
)}
{accounts.hasNextPage && !accounts.isError && (
<Chip
fullWidth
disabled={accounts.isFetching}
onClick={() => accounts.fetchNextPage()}
>
{accounts.isFetchingNextPage ? 'Loading…' : 'Load more'}
</Chip>
)}
</>
)}
</SettingsPanel>

<ChipConfirmModal
open={pendingDisconnect !== null}
onOpenChange={(open) => {
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…',
}}
/>
</>
)
}
34 changes: 30 additions & 4 deletions apps/sim/hooks/queries/organization-accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
type InviteOrganizationAccountPeopleBody,
inviteOrganizationAccountPeopleContract,
listOrganizationAccountPeopleContract,
listPersonalOrganizationAccountsContract,
type OrganizationAccountPeopleQuery,
type RemoveOrganizationAccountMcpProviderParams,
type ResendOrganizationAccountInvitationQuery,
Expand Down Expand Up @@ -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() }),
Expand All @@ -127,6 +151,7 @@ export function useDisconnectPersonalOrganizationAccount(organizationId: string)
queryClient.invalidateQueries({
queryKey: organizationAccountsKeys.detail(organizationId),
}),
queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }),
])
router.refresh()
},
Expand All @@ -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,
Expand Down
2 changes: 0 additions & 2 deletions scripts/check-unused-exports.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down