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;
+ }) => {confirmButtonText},
+}));
+
+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 && (
-
- triggerAppOAuth({
- applicationId,
- providerName: item.providerName,
- visibility: item.visibility,
- reconnectingConnectedAccountId: item.id,
- })
- }
- />
- )}
-
- deleteConnectedAccount({ variables: { id: item.id } })
- }
- />
-
- )}
- />
+ to={getSettingsPath(
+ SettingsPath.ApplicationConnectionDetail,
+ {
+ applicationId,
+ connectedAccountId: connection.id,
+ },
+ )}
+ >
+
+ {connection.name ?? connection.handle}
+
+
+ {connection.authFailedAt ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ ))}
+
+
)}
{isClientCredentialsConfigured && (
diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/__tests__/SettingsApplicationConnectionsSection.test.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/__tests__/SettingsApplicationConnectionsSection.test.tsx
new file mode 100644
index 0000000000..b62f4ccff9
--- /dev/null
+++ b/packages/twenty-front/src/pages/settings/applications/tabs/__tests__/SettingsApplicationConnectionsSection.test.tsx
@@ -0,0 +1,114 @@
+import { i18n } from '@lingui/core';
+import { I18nProvider } from '@lingui/react';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+import { SettingsApplicationConnectionsSection } from '~/pages/settings/applications/tabs/SettingsApplicationConnectionsSection';
+import { useFindApplicationConnectionProviders } from '~/pages/settings/applications/hooks/useFindApplicationConnectionProviders';
+import { useMyAppConnectedAccounts } from '~/pages/settings/applications/hooks/useMyAppConnectedAccounts';
+
+const mockTriggerAppOAuth = 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,
+ })),
+}));
+
+const mockedUseFindApplicationConnectionProviders =
+ useFindApplicationConnectionProviders as jest.MockedFunction<
+ typeof useFindApplicationConnectionProviders
+ >;
+
+const mockedUseMyAppConnectedAccounts =
+ useMyAppConnectedAccounts as jest.MockedFunction<
+ typeof useMyAppConnectedAccounts
+ >;
+
+describe('SettingsApplicationConnectionsSection', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders app connection rows as links to the connection detail page', () => {
+ mockedUseFindApplicationConnectionProviders.mockReturnValue({
+ connectionProviders: [
+ {
+ id: 'provider-1',
+ applicationId: 'app-1',
+ type: 'oauth',
+ name: 'google-calendar',
+ displayName: 'Google Calendar',
+ oauth: {
+ scopes: ['calendar.readonly'],
+ isClientCredentialsConfigured: false,
+ },
+ },
+ ],
+ loading: false,
+ refetch: jest.fn(),
+ });
+
+ mockedUseMyAppConnectedAccounts.mockReturnValue({
+ accounts: [
+ {
+ __typename: 'ConnectedAccountDTO',
+ id: 'account-1',
+ handle: 'workspace@example.com',
+ provider: 'app',
+ authFailedAt: '2026-05-01T00:00:00.000Z',
+ scopes: ['calendar.readonly'],
+ handleAliases: [],
+ lastSignedInAt: null,
+ userWorkspaceId: 'user-workspace-1',
+ connectionProviderId: 'provider-1',
+ name: 'Main connection',
+ visibility: 'workspace',
+ lastCredentialsRefreshedAt: null,
+ connectionParameters: null,
+ createdAt: '2026-05-01T00:00:00.000Z',
+ updatedAt: '2026-05-01T00:00:00.000Z',
+ },
+ ],
+ loading: false,
+ refetch: jest.fn(),
+ });
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(
+ screen.getByRole('link', { name: /Main connection/i }),
+ ).toHaveAttribute(
+ 'href',
+ '/settings/applications/app-1/connections/account-1',
+ );
+ expect(screen.getByText('Reconnect needed')).toBeVisible();
+ expect(screen.getByText('Workspace shared')).toBeVisible();
+ expect(
+ screen.queryByRole('button', { name: 'Reconnect' }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Delete' }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts
new file mode 100644
index 0000000000..81b2ec0f1d
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/__tests__/connection-provider-oauth-flow.service.spec.ts
@@ -0,0 +1,412 @@
+// SecureHttpClientService transitively depends on `@lifeomic/axios-fetch`,
+// which is an optional native-binding dep that's flaky in some test envs.
+// We never use the real implementation here (the test always injects a
+// mock via `useValue`), so stub the module to avoid loading the dep at all.
+jest.mock(
+ 'src/engine/core-modules/secure-http-client/secure-http-client.service',
+ () => ({
+ SecureHttpClientService: class {},
+ }),
+);
+
+import { Test, type TestingModule } from '@nestjs/testing';
+import { getRepositoryToken } from '@nestjs/typeorm';
+
+import { ConnectedAccountProvider } from 'twenty-shared/types';
+
+import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
+import { ConnectionProviderOAuthFlowService } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service';
+import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
+import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
+import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
+import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
+import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
+import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
+
+describe('ConnectionProviderOAuthFlowService', () => {
+ let service: ConnectionProviderOAuthFlowService;
+ let connectionProviderService: {
+ findOneByIdOrThrow: jest.Mock;
+ getClientCredentials: jest.Mock;
+ };
+ let jwtWrapperService: {
+ sign: jest.Mock;
+ verifyJwtToken: jest.Mock;
+ generateAppSecret: jest.Mock;
+ };
+ let secureHttpClientService: { createSsrfSafeFetch: jest.Mock };
+ let connectedAccountRepository: {
+ count: jest.Mock;
+ update: jest.Mock;
+ create: jest.Mock;
+ save: jest.Mock;
+ findOne: jest.Mock;
+ findOneByOrFail: jest.Mock;
+ };
+
+ const baseProvider: ConnectionProviderEntity = {
+ id: 'provider-1',
+ universalIdentifier: 'provider-uid',
+ applicationId: 'app-1',
+ workspaceId: 'workspace-1',
+ name: 'linear',
+ displayName: 'Linear',
+ type: 'oauth',
+ oauthConfig: {
+ authorizationEndpoint: 'https://linear.app/oauth/authorize',
+ tokenEndpoint: 'https://api.linear.app/oauth/token',
+ revokeEndpoint: null,
+ scopes: ['read', 'write'],
+ clientIdVariable: 'LINEAR_CLIENT_ID',
+ clientSecretVariable: 'LINEAR_CLIENT_SECRET',
+ authorizationParams: null,
+ tokenRequestContentType: 'form-urlencoded',
+ usePkce: false,
+ },
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ } as unknown as ConnectionProviderEntity;
+
+ beforeEach(async () => {
+ connectionProviderService = {
+ findOneByIdOrThrow: jest.fn(),
+ getClientCredentials: jest.fn(async () => ({
+ clientId: 'lin_client_id',
+ clientSecret: 'lin_client_secret',
+ })),
+ };
+ jwtWrapperService = {
+ sign: jest.fn(),
+ verifyJwtToken: jest.fn(),
+ generateAppSecret: jest.fn(() => 'derived-secret'),
+ };
+ secureHttpClientService = { createSsrfSafeFetch: jest.fn() };
+ connectedAccountRepository = {
+ count: jest.fn(async () => 0),
+ update: jest.fn(),
+ create: jest.fn((entity) => entity),
+ save: jest.fn(async (entity) => ({ ...entity, id: 'new-account-id' })),
+ findOne: jest.fn(async () => null),
+ findOneByOrFail: jest.fn(async ({ id }) => ({
+ id,
+ provider: ConnectedAccountProvider.APP,
+ })),
+ };
+
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ ConnectionProviderOAuthFlowService,
+ {
+ provide: ConnectionProviderService,
+ useValue: connectionProviderService,
+ },
+ { provide: JwtWrapperService, useValue: jwtWrapperService },
+ { provide: SecureHttpClientService, useValue: secureHttpClientService },
+ {
+ provide: TwentyConfigService,
+ useValue: { get: jest.fn(() => 'https://api.example.com') },
+ },
+ {
+ provide: getRepositoryToken(ConnectedAccountEntity),
+ useValue: connectedAccountRepository,
+ },
+ ],
+ }).compile();
+
+ service = module.get(ConnectionProviderOAuthFlowService);
+ });
+
+ afterEach(() => jest.clearAllMocks());
+
+ describe('startAuthorizationFlow', () => {
+ it('builds the provider authorization URL with the workspace + visibility context signed into state', async () => {
+ jwtWrapperService.sign.mockReturnValue('signed-state-token');
+
+ const { authorizationUrl } = await service.startAuthorizationFlow({
+ connectionProvider: baseProvider,
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ userWorkspaceId: 'uws-1',
+ visibility: 'user',
+ reconnectingConnectedAccountId: null,
+ redirectLocation: null,
+ });
+
+ const url = new URL(authorizationUrl);
+
+ expect(url.origin + url.pathname).toBe(
+ 'https://linear.app/oauth/authorize',
+ );
+ expect(url.searchParams.get('client_id')).toBe('lin_client_id');
+ expect(url.searchParams.get('response_type')).toBe('code');
+ // OAuth-standard `scope` (plural meaning) - these are the upstream
+ // permissions we're requesting, unrelated to the row-visibility field.
+ expect(url.searchParams.get('scope')).toBe('read write');
+ expect(url.searchParams.get('state')).toBe('signed-state-token');
+ expect(url.searchParams.get('redirect_uri')).toBe(
+ 'https://api.example.com/apps/oauth/callback',
+ );
+ expect(url.searchParams.has('code_challenge')).toBe(false);
+
+ // signed payload carries workspace identity for the callback to use
+ expect(jwtWrapperService.sign).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: JwtTokenTypeEnum.APP_OAUTH_STATE,
+ workspaceId: 'workspace-1',
+ connectionProviderId: 'provider-1',
+ visibility: 'user',
+ reconnectingConnectedAccountId: null,
+ }),
+ expect.objectContaining({ secret: 'derived-secret' }),
+ );
+ });
+
+ it('emits PKCE challenge params when usePkce is enabled', async () => {
+ jwtWrapperService.sign.mockReturnValue('signed-state');
+
+ const { authorizationUrl } = await service.startAuthorizationFlow({
+ connectionProvider: {
+ ...baseProvider,
+ oauthConfig: { ...baseProvider.oauthConfig!, usePkce: true },
+ },
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ userWorkspaceId: 'uws-1',
+ visibility: 'user',
+ reconnectingConnectedAccountId: null,
+ redirectLocation: null,
+ });
+
+ const url = new URL(authorizationUrl);
+
+ expect(url.searchParams.get('code_challenge_method')).toBe('S256');
+ expect(url.searchParams.get('code_challenge')).toMatch(/^[\w-]+$/);
+ });
+
+ describe('reconnect target validation', () => {
+ // Cross-workspace reconnect was a real bug: the persist UPDATE filtered
+ // by (id, workspaceId) so it wrote nothing, but the subsequent
+ // findOneByOrFail({ id }) returned the foreign-workspace row with stale
+ // tokens, making the reconnect look successful. Catch it at authorize
+ // time before the upstream OAuth round-trip.
+ const validateArgs = {
+ connectionProvider: baseProvider,
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ userWorkspaceId: 'uws-1',
+ visibility: 'user' as const,
+ redirectLocation: null,
+ };
+
+ it('throws FORBIDDEN when reconnecting an id that lives in another workspace', async () => {
+ connectedAccountRepository.findOne.mockResolvedValue(null);
+
+ const error = await service
+ .startAuthorizationFlow({
+ ...validateArgs,
+ reconnectingConnectedAccountId: 'foreign-account-id',
+ })
+ .catch((caught) => caught);
+
+ expect(error).toMatchObject({
+ code: 'FORBIDDEN',
+ });
+ expect(error.message).toContain('foreign-account-id');
+ expect(connectedAccountRepository.findOne).toHaveBeenCalledWith({
+ where: {
+ id: 'foreign-account-id',
+ workspaceId: 'workspace-1',
+ connectionProviderId: 'provider-1',
+ },
+ });
+ // No state JWT signed, no upstream URL built.
+ expect(jwtWrapperService.sign).not.toHaveBeenCalled();
+ });
+
+ it('throws FORBIDDEN when reconnecting an id that belongs to a different provider in the same workspace', async () => {
+ // findOne with the provider filter returns null even though the row
+ // exists in this workspace under a different provider.
+ connectedAccountRepository.findOne.mockResolvedValue(null);
+
+ await expect(
+ service.startAuthorizationFlow({
+ ...validateArgs,
+ reconnectingConnectedAccountId: 'wrong-provider-account-id',
+ }),
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
+ });
+
+ it('proceeds when the reconnect target matches workspace and provider', async () => {
+ connectedAccountRepository.findOne.mockResolvedValue({
+ id: 'existing-account-id',
+ workspaceId: 'workspace-1',
+ connectionProviderId: 'provider-1',
+ });
+ jwtWrapperService.sign.mockReturnValue('state');
+
+ const { authorizationUrl } = await service.startAuthorizationFlow({
+ ...validateArgs,
+ reconnectingConnectedAccountId: 'existing-account-id',
+ });
+
+ expect(new URL(authorizationUrl).searchParams.get('state')).toBe(
+ 'state',
+ );
+ expect(jwtWrapperService.sign).toHaveBeenCalled();
+ });
+
+ it('skips the lookup entirely when reconnectingConnectedAccountId is null', async () => {
+ jwtWrapperService.sign.mockReturnValue('state');
+
+ await service.startAuthorizationFlow({
+ ...validateArgs,
+ reconnectingConnectedAccountId: null,
+ });
+
+ expect(connectedAccountRepository.findOne).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('completeAuthorizationFlow', () => {
+ const stateClaims = {
+ sub: 'provider-1',
+ type: JwtTokenTypeEnum.APP_OAUTH_STATE,
+ connectionProviderId: 'provider-1',
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ userWorkspaceId: 'uws-1',
+ visibility: 'user' as const,
+ reconnectingConnectedAccountId: null,
+ redirectLocation: null,
+ codeVerifier: null,
+ };
+
+ const successfulTokenResponse = {
+ ok: true,
+ status: 200,
+ json: async () => ({
+ access_token: 'new_access',
+ refresh_token: 'new_refresh',
+ scope: 'read write',
+ }),
+ text: async () => '',
+ };
+
+ beforeEach(() => {
+ jwtWrapperService.verifyJwtToken.mockReturnValue(stateClaims);
+ connectionProviderService.findOneByIdOrThrow.mockResolvedValue(
+ baseProvider,
+ );
+ secureHttpClientService.createSsrfSafeFetch.mockReturnValue(
+ jest.fn(async () => successfulTokenResponse),
+ );
+ });
+
+ it('always creates a new ConnectedAccount when no reconnect id is supplied', async () => {
+ const result = await service.completeAuthorizationFlow({
+ code: 'auth_code',
+ state: 'signed-state',
+ });
+
+ expect(result.connectedAccountId).toBe('new-account-id');
+ expect(result.workspaceId).toBe('workspace-1');
+ expect(result.applicationId).toBe('app-1');
+
+ expect(connectedAccountRepository.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: ConnectedAccountProvider.APP,
+ accessToken: 'new_access',
+ refreshToken: 'new_refresh',
+ connectionProviderId: 'provider-1',
+ applicationId: 'app-1',
+ workspaceId: 'workspace-1',
+ userWorkspaceId: 'uws-1',
+ visibility: 'user',
+ }),
+ );
+ expect(connectedAccountRepository.save).toHaveBeenCalled();
+ expect(connectedAccountRepository.update).not.toHaveBeenCalled();
+ });
+
+ it('updates the existing ConnectedAccount when reconnectingConnectedAccountId is supplied', async () => {
+ jwtWrapperService.verifyJwtToken.mockReturnValue({
+ ...stateClaims,
+ reconnectingConnectedAccountId: 'existing-account-id',
+ });
+
+ const result = await service.completeAuthorizationFlow({
+ code: 'auth_code',
+ state: 'signed-state',
+ });
+
+ expect(result.connectedAccountId).toBe('existing-account-id');
+ expect(connectedAccountRepository.update).toHaveBeenCalledWith(
+ { id: 'existing-account-id', workspaceId: 'workspace-1' },
+ expect.objectContaining({
+ accessToken: 'new_access',
+ refreshToken: 'new_refresh',
+ authFailedAt: null,
+ visibility: 'user',
+ }),
+ );
+ // Defense-in-depth: the post-update read MUST also be workspace-scoped,
+ // otherwise a foreign-id that slipped past the authorize-time guard
+ // would still surface stale fields from another workspace.
+ expect(connectedAccountRepository.findOneByOrFail).toHaveBeenCalledWith({
+ id: 'existing-account-id',
+ workspaceId: 'workspace-1',
+ });
+ expect(connectedAccountRepository.create).not.toHaveBeenCalled();
+ });
+
+ it('updates visibility on an existing ConnectedAccount when reconnecting', async () => {
+ jwtWrapperService.verifyJwtToken.mockReturnValue({
+ ...stateClaims,
+ visibility: 'workspace',
+ reconnectingConnectedAccountId: 'existing-account-id',
+ });
+
+ await service.completeAuthorizationFlow({
+ code: 'auth_code',
+ state: 'signed-state',
+ });
+
+ expect(connectedAccountRepository.update).toHaveBeenCalledWith(
+ { id: 'existing-account-id', workspaceId: 'workspace-1' },
+ expect.objectContaining({
+ visibility: 'workspace',
+ }),
+ );
+ });
+
+ it('persists the workspace visibility when state asks for it', async () => {
+ jwtWrapperService.verifyJwtToken.mockReturnValue({
+ ...stateClaims,
+ visibility: 'workspace',
+ });
+
+ await service.completeAuthorizationFlow({
+ code: 'auth_code',
+ state: 'signed-state',
+ });
+
+ expect(connectedAccountRepository.create).toHaveBeenCalledWith(
+ expect.objectContaining({ visibility: 'workspace' }),
+ );
+ });
+
+ it('rejects an invalid state', async () => {
+ jwtWrapperService.verifyJwtToken.mockImplementation(() => {
+ throw new Error('JWT expired');
+ });
+
+ await expect(
+ service.completeAuthorizationFlow({
+ code: 'auth_code',
+ state: 'bad-state',
+ }),
+ ).rejects.toThrow(/state/);
+ });
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts
index b3a4d1d4af..585e8443c6 100644
--- a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts
@@ -254,6 +254,7 @@ export class ConnectionProviderOAuthFlowService {
scopes: tokenResponse.scopes ?? provider.oauthConfig.scopes,
lastCredentialsRefreshedAt: new Date(),
authFailedAt: null,
+ visibility,
};
if (isDefined(reconnectingConnectedAccountId)) {
diff --git a/packages/twenty-shared/src/types/SettingsPath.ts b/packages/twenty-shared/src/types/SettingsPath.ts
index 640f680bbe..a43fa55c95 100644
--- a/packages/twenty-shared/src/types/SettingsPath.ts
+++ b/packages/twenty-shared/src/types/SettingsPath.ts
@@ -42,6 +42,7 @@ export enum SettingsPath {
AiToolDetail = 'ai/tools/:toolIdentifier',
Applications = 'applications',
ApplicationDetail = 'applications/:applicationId',
+ ApplicationConnectionDetail = 'applications/:applicationId/connections/:connectedAccountId',
ApplicationLogicFunctionDetail = 'applications/:applicationId/logicFunctions/:logicFunctionId',
ApplicationFrontComponentDetail = 'applications/:applicationId/frontComponents/:frontComponentId',
ApplicationCommandMenuItemDetail = 'applications/:applicationId/commandMenuItems/:commandMenuItemId',
diff --git a/packages/twenty-shared/src/utils/navigation/__tests__/getSettingsPath.test.ts b/packages/twenty-shared/src/utils/navigation/__tests__/getSettingsPath.test.ts
index 37f3b54765..2cd71d29a3 100644
--- a/packages/twenty-shared/src/utils/navigation/__tests__/getSettingsPath.test.ts
+++ b/packages/twenty-shared/src/utils/navigation/__tests__/getSettingsPath.test.ts
@@ -33,6 +33,13 @@ describe('getSettingsPath', () => {
connectedAccountId: 'account123',
}),
).toBe('/settings/accounts/edit-imap-smtp-caldav-connection/account123');
+
+ expect(
+ getSettingsPath(SettingsPath.ApplicationConnectionDetail, {
+ applicationId: 'app123',
+ connectedAccountId: 'account123',
+ }),
+ ).toBe('/settings/applications/app123/connections/account123');
});
it('should append query params when provided', () => {