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`
This commit is contained in:
bitloi
2026-05-07 15:29:02 -03:00
committed by GitHub
parent 86eab9ac24
commit 7999cd3dde
9 changed files with 1234 additions and 68 deletions
@@ -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={<SettingsApplicationDetails />}
/>
<Route
path={SettingsPath.ApplicationConnectionDetail}
element={<SettingsApplicationConnectionDetail />}
/>
<Route
path={SettingsPath.AvailableApplicationDetail}
element={<SettingsAvailableApplicationDetails />}
@@ -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: <StyledMonoText>{connection.handle}</StyledMonoText>,
},
{
key: 'visibility',
label: t`Visibility`,
value: (
<Status
color={connection.visibility === 'workspace' ? 'blue' : 'gray'}
text={
connection.visibility === 'workspace'
? t`Workspace shared`
: t`Just for me`
}
/>
),
},
{
key: 'status',
label: t`Status`,
value: connection.authFailedAt ? (
<Status color="red" text={t`Reconnect needed`} />
) : (
<Status color="green" text={t`Connected`} />
),
},
{
key: 'scopes',
label: t`Granted OAuth scopes`,
value:
scopes.length > 0 ? (
<StyledScopeList>
{scopes.map((scope) => (
<Tag key={scope} color="gray" text={scope} />
))}
</StyledScopeList>
) : (
'-'
),
},
{
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 (
<SubMenuTopBarContainer
title={connectionLabel}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{
children: applicationName,
href: applicationSettingsPath,
},
{ children: connectionLabel },
]}
>
<SettingsPageContainer>
{isLoading ? (
<SettingsSectionSkeletonLoader />
) : connection === undefined || provider === undefined ? (
<Section>
<H2Title
title={t`Connection not found`}
description={t`This connection does not exist or is not available for this application.`}
/>
</Section>
) : (
<>
<Section>
<H2Title
title={connectionLabel}
description={t`Manage this application's OAuth connection.`}
/>
<StyledActions>
{connection.authFailedAt && (
<Button
title={t`Reconnect`}
Icon={IconRefresh}
variant="secondary"
accent="blue"
onClick={handleReconnect}
/>
)}
<Button
title={
connection.visibility === 'workspace'
? t`Make private`
: t`Share with workspace`
}
Icon={
connection.visibility === 'workspace' ? IconUser : IconUsers
}
variant="secondary"
accent="default"
onClick={() => openModal(changeVisibilityModalId)}
/>
<Button
title={t`Disconnect`}
Icon={IconTrash}
variant="secondary"
accent="danger"
onClick={() => openModal(deleteModalId)}
/>
</StyledActions>
</Section>
<Section>
<H2Title
title={t`Details`}
description={t`OAuth credential metadata for this application connection`}
/>
<Table>
<TableRow gridTemplateColumns={DETAIL_GRID_TEMPLATE}>
<TableHeader>{t`Property`}</TableHeader>
<TableHeader>{t`Value`}</TableHeader>
</TableRow>
<TableSection title={t`Connection`}>
{detailRows.map((row) => (
<TableRow
key={row.key}
gridTemplateColumns={DETAIL_GRID_TEMPLATE}
>
<TableCell color={themeCssVariables.font.color.secondary}>
{row.label}
</TableCell>
<TableCell minWidth="0" overflow="hidden">
{row.value}
</TableCell>
</TableRow>
))}
</TableSection>
</Table>
</Section>
<ConfirmationModal
modalInstanceId={deleteModalId}
title={t`Disconnect connection?`}
subtitle={
<Trans>
This will disconnect {connectionLabel} from this application.
</Trans>
}
onConfirmClick={handleDelete}
confirmButtonText={t`Disconnect`}
loading={isDeleting}
/>
<ConfirmationModal
modalInstanceId={changeVisibilityModalId}
title={t`Change visibility?`}
subtitle={
<Trans>
Changing visibility requires reconnecting this OAuth
connection. You will be redirected to authorize it again.
</Trans>
}
onConfirmClick={handleChangeVisibility}
confirmButtonText={t`Reconnect and change visibility`}
confirmButtonAccent="blue"
/>
</>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -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;
}) => <button onClick={onConfirmClick}>{confirmButtonText}</button>,
}));
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<typeof useQuery>;
const mockedUseFindApplicationConnectionProviders =
useFindApplicationConnectionProviders as jest.MockedFunction<
typeof useFindApplicationConnectionProviders
>;
const mockedUseMyAppConnectedAccounts =
useMyAppConnectedAccounts as jest.MockedFunction<
typeof useMyAppConnectedAccounts
>;
const renderDetailPage = () =>
render(
<I18nProvider i18n={i18n}>
<MemoryRouter
initialEntries={['/settings/applications/app-1/connections/account-1']}
>
<Routes>
<Route
path="/settings/applications/:applicationId/connections/:connectedAccountId"
element={<SettingsApplicationConnectionDetail />}
/>
</Routes>
</MemoryRouter>
</I18nProvider>,
);
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',
});
});
});
@@ -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 && (
<SettingsListCard
items={providerConnections.map((connection) => ({
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 }) => (
<StyledRowRightContainer>
<Status
color={item.visibility === 'workspace' ? 'blue' : 'gray'}
text={
item.visibility === 'workspace'
? t`Workspace`
: t`Just me`
<Table>
<TableRow
gridTemplateColumns={
CONNECTION_TABLE_ROW_GRID_TEMPLATE_COLUMNS
}
>
<TableHeader>{t`Connection`}</TableHeader>
<TableHeader>{t`Status`}</TableHeader>
<TableHeader>{t`Visibility`}</TableHeader>
<TableHeader />
</TableRow>
<StyledTableRowsContainer>
{providerConnections.map((connection) => (
<TableRow
key={connection.id}
gridTemplateColumns={
CONNECTION_TABLE_ROW_GRID_TEMPLATE_COLUMNS
}
/>
{item.authFailedAt && (
<Status color="red" text={t`Reconnect needed`} />
)}
{item.authFailedAt && (
<Button
title={t`Reconnect`}
variant="secondary"
accent="blue"
size="small"
onClick={() =>
triggerAppOAuth({
applicationId,
providerName: item.providerName,
visibility: item.visibility,
reconnectingConnectedAccountId: item.id,
})
}
/>
)}
<Button
title={t`Delete`}
variant="secondary"
accent="danger"
size="small"
onClick={() =>
deleteConnectedAccount({ variables: { id: item.id } })
}
/>
</StyledRowRightContainer>
)}
/>
to={getSettingsPath(
SettingsPath.ApplicationConnectionDetail,
{
applicationId,
connectedAccountId: connection.id,
},
)}
>
<TableCell
clickable
minWidth="0"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{connection.name ?? connection.handle}
</TableCell>
<TableCell clickable>
{connection.authFailedAt ? (
<Status color="red" text={t`Reconnect needed`} />
) : (
<Status color="green" text={t`Connected`} />
)}
</TableCell>
<TableCell clickable>
<Status
color={
connection.visibility === 'workspace'
? 'blue'
: 'gray'
}
text={
connection.visibility === 'workspace'
? t`Workspace shared`
: t`Just for me`
}
/>
</TableCell>
<TableCell
align="right"
color={themeCssVariables.font.color.tertiary}
padding={`0 ${themeCssVariables.spacing[2]} 0 0`}
>
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.light}
/>
</TableCell>
</TableRow>
))}
</StyledTableRowsContainer>
</Table>
)}
{isClientCredentialsConfigured && (
<StyledFooter>
@@ -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(
<I18nProvider i18n={i18n}>
<MemoryRouter>
<SettingsApplicationConnectionsSection applicationId="app-1" />
</MemoryRouter>
</I18nProvider>,
);
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();
});
});
@@ -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/);
});
});
});
@@ -254,6 +254,7 @@ export class ConnectionProviderOAuthFlowService {
scopes: tokenResponse.scopes ?? provider.oauthConfig.scopes,
lastCredentialsRefreshedAt: new Date(),
authFailedAt: null,
visibility,
};
if (isDefined(reconnectingConnectedAccountId)) {
@@ -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',
@@ -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', () => {