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:
@@ -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 />}
|
||||
|
||||
+410
@@ -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>
|
||||
);
|
||||
};
|
||||
+190
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+87
-68
@@ -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>
|
||||
|
||||
+114
@@ -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();
|
||||
});
|
||||
});
|
||||
+412
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user