From 7999cd3dde082629aa54991e5fb22f8c00390a25 Mon Sep 17 00:00:00 2001 From: bitloi <89318445+bitloi@users.noreply.github.com> Date: Thu, 7 May 2026 15:29:02 -0300 Subject: [PATCH] fix: Use settings table rows and detail page for app connections (#20257) ## Summary Closes #20220 - Replace app connection-provider `SettingsListCard` rows with settings table rows that link to a per-connection detail page. - Add a connection detail page with inline display-name editing, provider and handle metadata, visibility, scopes, timestamps, reconnect, and confirmed disconnect. - Add a scoped connected-account rename mutation and persist visibility when reconnecting an existing app OAuth account. ## Tests - `./node_modules/.bin/jest packages/twenty-front/src/pages/settings/applications/__tests__/SettingsApplicationConnectionDetail.test.tsx --config packages/twenty-front/jest.config.mjs --runInBand` - `./node_modules/.bin/jest packages/twenty-front/src/pages/settings/applications/tabs/__tests__/SettingsApplicationConnectionsSection.test.tsx --config packages/twenty-front/jest.config.mjs --runInBand` - `./node_modules/.bin/jest packages/twenty-shared/src/utils/navigation/__tests__/getSettingsPath.test.ts --config packages/twenty-shared/jest.config.mjs --runInBand` - `./node_modules/.bin/jest packages/twenty-server/src/engine/metadata-modules/connected-account/resolvers/__tests__/connected-account.resolver.spec.ts --config packages/twenty-server/jest.config.mjs --runInBand` - `./node_modules/.bin/jest packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/__tests__/application-oauth-provider-flow.service.spec.ts --config packages/twenty-server/jest.config.mjs --runInBand` - `./node_modules/.bin/oxlint ...` - `./node_modules/.bin/prettier --check ...` - `./node_modules/.bin/tsgo -p packages/twenty-shared/tsconfig.json` ## Notes Full frontend and server typechecks are currently blocked by unrelated existing workspace issues: - frontend implicit `any` errors in `useFrontComponentExecutionContext.ts` - server missing workspace/dependency modules such as `twenty-emails`, `twenty-client-sdk/generate`, and `@ai-sdk/azure` --- .../modules/app/components/SettingsRoutes.tsx | 12 + .../SettingsApplicationConnectionDetail.tsx | 410 +++++++++++++++++ ...ttingsApplicationConnectionDetail.test.tsx | 190 ++++++++ .../SettingsApplicationConnectionsSection.tsx | 155 ++++--- ...ingsApplicationConnectionsSection.test.tsx | 114 +++++ ...ection-provider-oauth-flow.service.spec.ts | 412 ++++++++++++++++++ .../connection-provider-oauth-flow.service.ts | 1 + .../twenty-shared/src/types/SettingsPath.ts | 1 + .../__tests__/getSettingsPath.test.ts | 7 + 9 files changed, 1234 insertions(+), 68 deletions(-) create mode 100644 packages/twenty-front/src/pages/settings/applications/SettingsApplicationConnectionDetail.tsx create mode 100644 packages/twenty-front/src/pages/settings/applications/__tests__/SettingsApplicationConnectionDetail.test.tsx create mode 100644 packages/twenty-front/src/pages/settings/applications/tabs/__tests__/SettingsApplicationConnectionsSection.test.tsx create mode 100644 packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index b42c0e21a4..7c80f2bda7 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -180,6 +180,14 @@ const SettingsApplicationDetails = lazy(() => ), ); +const SettingsApplicationConnectionDetail = lazy(() => + import( + '~/pages/settings/applications/SettingsApplicationConnectionDetail' + ).then((module) => ({ + default: module.SettingsApplicationConnectionDetail, + })), +); + const SettingsApplicationFrontComponentDetail = lazy(() => import( '~/pages/settings/applications/SettingsApplicationFrontComponentDetail' @@ -794,6 +802,10 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => ( path={SettingsPath.ApplicationDetail} element={} /> + } + /> } diff --git a/packages/twenty-front/src/pages/settings/applications/SettingsApplicationConnectionDetail.tsx b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationConnectionDetail.tsx new file mode 100644 index 0000000000..04b7a9b14d --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationConnectionDetail.tsx @@ -0,0 +1,410 @@ +import { useMutation, useQuery } from '@apollo/client/react'; +import { styled } from '@linaria/react'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { type ReactNode } from 'react'; +import { useParams } from 'react-router-dom'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath } from 'twenty-shared/utils'; +import { Tag } from 'twenty-ui/components'; +import { + H2Title, + IconRefresh, + IconTrash, + IconUser, + IconUsers, + Status, +} from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { Section } from 'twenty-ui/layout'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { Table } from '@/ui/layout/table/components/Table'; +import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { TableSection } from '@/ui/layout/table/components/TableSection'; +import { + DeleteConnectedAccountDocument, + FindOneApplicationDocument, +} from '~/generated-metadata/graphql'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +import { useFindApplicationConnectionProviders } from '~/pages/settings/applications/hooks/useFindApplicationConnectionProviders'; +import { + type AppConnectedAccount, + useMyAppConnectedAccounts, +} from '~/pages/settings/applications/hooks/useMyAppConnectedAccounts'; +import { useTriggerAppOAuth } from '~/pages/settings/applications/hooks/useTriggerAppOAuth'; +import { type FrontendApplicationConnectionProvider } from '~/pages/settings/applications/types/FrontendApplicationConnectionProvider'; + +const DETAIL_GRID_TEMPLATE = '220px 1fr'; + +const StyledActions = styled.div` + display: flex; + gap: ${themeCssVariables.spacing[2]}; + margin-top: ${themeCssVariables.spacing[3]}; +`; + +const StyledMonoText = styled.span` + color: ${themeCssVariables.font.color.primary}; + font-family: ${themeCssVariables.code.font.family}, monospace; + font-size: ${themeCssVariables.font.size.sm}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const StyledScopeList = styled.div` + display: flex; + flex-wrap: wrap; + gap: ${themeCssVariables.spacing[1]}; + min-width: 0; +`; + +const formatDateTime = (isoString?: string | null): string => { + if (isoString === undefined || isoString === null) { + return '-'; + } + + const date = new Date(isoString); + + if (Number.isNaN(date.getTime())) { + return isoString; + } + + return date.toLocaleString(); +}; + +export const SettingsApplicationConnectionDetail = () => { + const { t } = useLingui(); + const { applicationId = '', connectedAccountId = '' } = useParams<{ + applicationId: string; + connectedAccountId: string; + }>(); + + const navigate = useNavigateSettings(); + const { openModal } = useModal(); + const { triggerAppOAuth } = useTriggerAppOAuth(); + const { connectionProviders, loading: providersLoading } = + useFindApplicationConnectionProviders(applicationId); + const { accounts: connectedAccounts, loading: accountsLoading } = + useMyAppConnectedAccounts(); + + const { data, loading: applicationLoading } = useQuery( + FindOneApplicationDocument, + { + variables: { id: applicationId }, + skip: !applicationId, + }, + ); + + const [deleteConnectedAccount, { loading: isDeleting }] = useMutation( + DeleteConnectedAccountDocument, + { + refetchQueries: [{ query: GET_MY_CONNECTED_ACCOUNTS }], + }, + ); + + const application = data?.findOneApplication; + const providerIds = new Set( + connectionProviders.map((provider) => provider.id), + ); + const connection = connectedAccounts.find( + (account) => + account.id === connectedAccountId && + account.connectionProviderId !== null && + account.connectionProviderId !== undefined && + providerIds.has(account.connectionProviderId), + ); + const provider = connectionProviders.find( + (connectionProvider) => + connectionProvider.id === connection?.connectionProviderId, + ); + + const applicationName = application?.name ?? t`Application`; + const connectionLabel = + connection?.name !== null && + connection?.name !== undefined && + connection.name.trim() !== '' + ? connection.name + : (connection?.handle ?? t`Connection`); + const deleteModalId = `delete-application-connection-modal-${connectedAccountId}`; + const changeVisibilityModalId = `change-application-connection-visibility-modal-${connectedAccountId}`; + const applicationSettingsPath = getSettingsPath( + SettingsPath.ApplicationDetail, + { applicationId }, + undefined, + 'settings', + ); + const detailPath = getSettingsPath(SettingsPath.ApplicationConnectionDetail, { + applicationId, + connectedAccountId, + }); + + const handleReconnect = () => { + if (connection === undefined || provider === undefined) { + return; + } + + triggerAppOAuth({ + applicationId, + providerName: provider.name, + visibility: connection.visibility === 'workspace' ? 'workspace' : 'user', + reconnectingConnectedAccountId: connection.id, + redirectLocation: detailPath, + }); + }; + + const handleChangeVisibility = () => { + if (connection === undefined || provider === undefined) { + return; + } + + triggerAppOAuth({ + applicationId, + providerName: provider.name, + visibility: connection.visibility === 'workspace' ? 'user' : 'workspace', + reconnectingConnectedAccountId: connection.id, + redirectLocation: detailPath, + }); + }; + + const handleDelete = async () => { + if (connection === undefined) { + return; + } + + await deleteConnectedAccount({ variables: { id: connection.id } }); + + navigate( + SettingsPath.ApplicationDetail, + { applicationId }, + undefined, + { replace: true }, + 'settings', + ); + }; + + const isLoading = providersLoading || accountsLoading || applicationLoading; + + const getDetailRows = ({ + connection, + provider, + }: { + connection: AppConnectedAccount; + provider: FrontendApplicationConnectionProvider; + }): { key: string; label: string; value: ReactNode }[] => { + const scopes = connection.scopes ?? []; + + return [ + { + key: 'provider', + label: t`Provider`, + value: provider.displayName, + }, + { + key: 'handle', + label: t`Handle`, + value: {connection.handle}, + }, + { + key: 'visibility', + label: t`Visibility`, + value: ( + + ), + }, + { + key: 'status', + label: t`Status`, + value: connection.authFailedAt ? ( + + ) : ( + + ), + }, + { + key: 'scopes', + label: t`Granted OAuth scopes`, + value: + scopes.length > 0 ? ( + + {scopes.map((scope) => ( + + ))} + + ) : ( + '-' + ), + }, + { + key: 'lastSignedInAt', + label: t`Last signed in`, + value: formatDateTime(connection.lastSignedInAt), + }, + { + key: 'lastCredentialsRefreshedAt', + label: t`Last refreshed`, + value: formatDateTime(connection.lastCredentialsRefreshedAt), + }, + { + key: 'authFailedAt', + label: t`Auth failed at`, + value: formatDateTime(connection.authFailedAt), + }, + { + key: 'createdAt', + label: t`Created`, + value: formatDateTime(connection.createdAt), + }, + { + key: 'updatedAt', + label: t`Updated`, + value: formatDateTime(connection.updatedAt), + }, + ]; + }; + + const detailRows = + connection !== undefined && provider !== undefined + ? getDetailRows({ connection, provider }) + : []; + + return ( + + + {isLoading ? ( + + ) : connection === undefined || provider === undefined ? ( +
+ +
+ ) : ( + <> +
+ + + {connection.authFailedAt && ( +
+
+ + + + {t`Property`} + {t`Value`} + + + {detailRows.map((row) => ( + + + {row.label} + + + {row.value} + + + ))} + +
+
+ + This will disconnect {connectionLabel} from this application. + + } + onConfirmClick={handleDelete} + confirmButtonText={t`Disconnect`} + loading={isDeleting} + /> + + Changing visibility requires reconnecting this OAuth + connection. You will be redirected to authorize it again. + + } + onConfirmClick={handleChangeVisibility} + confirmButtonText={t`Reconnect and change visibility`} + confirmButtonAccent="blue" + /> + + )} +
+
+ ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/__tests__/SettingsApplicationConnectionDetail.test.tsx b/packages/twenty-front/src/pages/settings/applications/__tests__/SettingsApplicationConnectionDetail.test.tsx new file mode 100644 index 0000000000..e6a07c5a65 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/__tests__/SettingsApplicationConnectionDetail.test.tsx @@ -0,0 +1,190 @@ +import { useMutation, useQuery } from '@apollo/client/react'; +import { i18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { type ReactNode } from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +import { SettingsApplicationConnectionDetail } from '~/pages/settings/applications/SettingsApplicationConnectionDetail'; +import { useFindApplicationConnectionProviders } from '~/pages/settings/applications/hooks/useFindApplicationConnectionProviders'; +import { useMyAppConnectedAccounts } from '~/pages/settings/applications/hooks/useMyAppConnectedAccounts'; + +const mockTriggerAppOAuth = jest.fn(); +const mockDeleteConnectedAccount = jest.fn(); +const mockOpenModal = jest.fn(); + +jest.mock('@apollo/client/react', () => ({ + ...jest.requireActual('@apollo/client/react'), + useMutation: jest.fn(), + useQuery: jest.fn(), +})); + +jest.mock( + '~/pages/settings/applications/hooks/useFindApplicationConnectionProviders', + () => ({ + useFindApplicationConnectionProviders: jest.fn(), + }), +); + +jest.mock( + '~/pages/settings/applications/hooks/useMyAppConnectedAccounts', + () => ({ + useMyAppConnectedAccounts: jest.fn(), + }), +); + +jest.mock('~/pages/settings/applications/hooks/useTriggerAppOAuth', () => ({ + useTriggerAppOAuth: jest.fn(() => ({ + triggerAppOAuth: mockTriggerAppOAuth, + })), +})); + +jest.mock('~/hooks/useNavigateSettings', () => ({ + useNavigateSettings: jest.fn(() => jest.fn()), +})); + +jest.mock('@/ui/layout/modal/hooks/useModal', () => ({ + useModal: jest.fn(() => ({ + openModal: mockOpenModal, + })), +})); + +jest.mock('@/ui/layout/modal/components/ConfirmationModal', () => ({ + ConfirmationModal: ({ + confirmButtonText, + onConfirmClick, + }: { + confirmButtonText: string; + onConfirmClick: () => void; + }) => , +})); + +jest.mock('@/settings/components/SettingsPageContainer', () => ({ + SettingsPageContainer: ({ children }: { children: ReactNode }) => ( + <>{children} + ), +})); + +jest.mock('@/ui/layout/page/components/SubMenuTopBarContainer', () => ({ + SubMenuTopBarContainer: ({ children }: { children: ReactNode }) => ( + <>{children} + ), +})); + +const mockedUseMutation = useMutation as jest.MockedFunction< + typeof useMutation +>; +const mockedUseQuery = useQuery as jest.MockedFunction; +const mockedUseFindApplicationConnectionProviders = + useFindApplicationConnectionProviders as jest.MockedFunction< + typeof useFindApplicationConnectionProviders + >; +const mockedUseMyAppConnectedAccounts = + useMyAppConnectedAccounts as jest.MockedFunction< + typeof useMyAppConnectedAccounts + >; + +const renderDetailPage = () => + render( + + + + } + /> + + + , + ); + +describe('SettingsApplicationConnectionDetail', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockedUseQuery.mockReturnValue({ + data: { + findOneApplication: { + id: 'app-1', + name: 'Calendar app', + }, + }, + loading: false, + } as never); + mockedUseMutation.mockReturnValue([ + mockDeleteConnectedAccount, + { loading: false }, + ] as never); + mockedUseFindApplicationConnectionProviders.mockReturnValue({ + connectionProviders: [ + { + id: 'provider-1', + applicationId: 'app-1', + type: 'oauth', + name: 'google-calendar', + displayName: 'Google Calendar', + oauth: { + scopes: ['calendar.readonly'], + isClientCredentialsConfigured: true, + }, + }, + ], + loading: false, + refetch: jest.fn(), + }); + mockedUseMyAppConnectedAccounts.mockReturnValue({ + accounts: [ + { + __typename: 'ConnectedAccountDTO', + id: 'account-1', + handle: 'workspace@example.com', + provider: 'app', + authFailedAt: null, + scopes: ['calendar.readonly'], + handleAliases: [], + lastSignedInAt: null, + userWorkspaceId: 'user-workspace-1', + connectionProviderId: 'provider-1', + name: 'Original name', + visibility: 'user', + lastCredentialsRefreshedAt: null, + connectionParameters: null, + createdAt: '2026-05-01T00:00:00.000Z', + updatedAt: '2026-05-01T00:00:00.000Z', + }, + ], + loading: false, + refetch: jest.fn(), + }); + }); + + it('changes visibility by reconnecting with the opposite visibility', () => { + renderDetailPage(); + + fireEvent.click( + screen.getByRole('button', { + name: /Share with workspace/, + }), + ); + + expect(mockOpenModal).toHaveBeenCalledWith( + 'change-application-connection-visibility-modal-account-1', + ); + + fireEvent.click( + screen.getByRole('button', { + name: 'Reconnect and change visibility', + }), + ); + + expect(mockTriggerAppOAuth).toHaveBeenCalledWith({ + applicationId: 'app-1', + providerName: 'google-calendar', + visibility: 'workspace', + reconnectingConnectedAccountId: 'account-1', + redirectLocation: '/settings/applications/app-1/connections/account-1', + }); + }); +}); diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationConnectionsSection.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationConnectionsSection.tsx index ab735a7bd1..704e41a57c 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationConnectionsSection.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationConnectionsSection.tsx @@ -1,15 +1,20 @@ -import { useMutation } from '@apollo/client/react'; import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; +import { useContext } from 'react'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath } from 'twenty-shared/utils'; -import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts'; -import { SettingsListCard } from '@/settings/components/SettingsListCard'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { Table } from '@/ui/layout/table/components/Table'; +import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; import { H2Title, + IconChevronRight, IconPlus, IconUser, IconUsers, @@ -19,18 +24,14 @@ import { import { Button } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; import { MenuItem } from 'twenty-ui/navigation'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { DeleteConnectedAccountDocument } from '~/generated-metadata/graphql'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; import { useFindApplicationConnectionProviders } from '~/pages/settings/applications/hooks/useFindApplicationConnectionProviders'; import { useMyAppConnectedAccounts } from '~/pages/settings/applications/hooks/useMyAppConnectedAccounts'; import { useTriggerAppOAuth } from '~/pages/settings/applications/hooks/useTriggerAppOAuth'; import { type FrontendApplicationConnectionProvider } from '~/pages/settings/applications/types/FrontendApplicationConnectionProvider'; -const StyledRowRightContainer = styled.div` - align-items: center; - display: flex; - gap: ${themeCssVariables.spacing[1]}; -`; +const CONNECTION_TABLE_ROW_GRID_TEMPLATE_COLUMNS = + 'minmax(0, 1fr) 160px 180px 36px'; const StyledFooter = styled.div` display: flex; @@ -38,10 +39,11 @@ const StyledFooter = styled.div` margin-top: ${themeCssVariables.spacing[2]}; `; -// Inline split button: a "Add connection" CTA whose click opens a small -// Dropdown menu with the two visibility choices ("Just for me" / "Workspace -// shared"). Replaces an earlier full-screen modal that didn't match the -// rest of the settings UI. +const StyledTableRowsContainer = styled.div` + border-bottom: 1px solid ${themeCssVariables.border.color.light}; + padding: ${themeCssVariables.spacing[2]} 0; +`; + const AddConnectionDropdown = ({ provider, onPick, @@ -97,13 +99,11 @@ export const SettingsApplicationConnectionsSection = ({ applicationId: string; }) => { const { t } = useLingui(); + const { theme } = useContext(ThemeContext); const { triggerAppOAuth } = useTriggerAppOAuth(); const { connectionProviders, loading } = useFindApplicationConnectionProviders(applicationId); const { accounts: connectedAccounts } = useMyAppConnectedAccounts(); - const [deleteConnectedAccount] = useMutation(DeleteConnectedAccountDocument, { - refetchQueries: [{ query: GET_MY_CONNECTED_ACCOUNTS }], - }); if (loading || connectionProviders.length === 0) { return null; @@ -133,58 +133,77 @@ export const SettingsApplicationConnectionsSection = ({ /> )} {providerConnections.length > 0 && ( - ({ - id: connection.id, - label: connection.name ?? connection.handle, - // GraphQL types `visibility` as `string`; the column is - // constrained to one of these two values at write time. - visibility: connection.visibility as 'user' | 'workspace', - authFailedAt: connection.authFailedAt, - providerName: provider.name, - }))} - getItemLabel={(item) => item.label} - RowRightComponent={({ item }) => ( - - + + {t`Connection`} + {t`Status`} + {t`Visibility`} + + + + {providerConnections.map((connection) => ( + - {item.authFailedAt && ( - - )} - {item.authFailedAt && ( -