Api keys and webhook migration to core (#13011)

TODO: check Zapier trigger records work as expected

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
nitin
2025-07-09 20:33:54 +05:30
committed by GitHub
parent 18792f9f74
commit 484c267aa6
113 changed files with 4563 additions and 1060 deletions

View File

@ -1,6 +1,5 @@
import { Meta, StoryObj } from '@storybook/react';
import { fireEvent, userEvent, within } from '@storybook/test';
import { HttpResponse, graphql } from 'msw';
import { SettingsDevelopersApiKeyDetail } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail';
import {
@ -21,26 +20,7 @@ const meta: Meta<PageDecoratorArgs> = {
},
},
parameters: {
msw: {
handlers: [
...graphqlMocks.handlers,
graphql.query('FindOneApiKey', () => {
return HttpResponse.json({
data: {
apiKey: {
__typename: 'ApiKey',
id: 'f7c6d736-8fcd-4e9c-ab99-28f6a9031570',
revokedAt: null,
expiresAt: '2024-03-10T09:23:10.511Z',
name: 'sfsfdsf',
updatedAt: '2024-02-24T10:23:10.673Z',
createdAt: '2024-02-24T10:23:10.673Z',
},
},
});
}),
],
},
msw: graphqlMocks,
},
};
export default meta;
@ -50,14 +30,14 @@ export type Story = StoryObj<typeof SettingsDevelopersApiKeyDetail>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('sfsfdsf', undefined, { timeout: 3000 });
await canvas.findByText('Zapier Integration', undefined, { timeout: 3000 });
},
};
export const RegenerateApiKey: Story = {
play: async ({ step }) => {
const canvas = within(document.body);
await canvas.findByText('sfsfdsf', undefined, { timeout: 3000 });
await canvas.findByText('Zapier Integration', undefined, { timeout: 3000 });
await userEvent.click(await canvas.findByText('Regenerate Key'));
@ -85,7 +65,7 @@ export const RegenerateApiKey: Story = {
export const DeleteApiKey: Story = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await canvas.findByText('sfsfdsf', undefined, { timeout: 3000 });
await canvas.findByText('Zapier Integration', undefined, { timeout: 3000 });
await userEvent.click(await canvas.findByText('Delete'));

View File

@ -27,7 +27,7 @@ export type Story = StoryObj<typeof SettingsDevelopersWebhookNew>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('New Webhook', undefined, { timeout: 10000 });
await canvas.findByText('New Webhook', undefined, { timeout: 3000 });
await canvas.findByText(
'We will send POST requests to this endpoint for every new event',
);

View File

@ -1,5 +1,5 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { expect, within } from '@storybook/test';
import {
PageDecorator,
@ -28,11 +28,20 @@ export type Story = StoryObj<typeof SettingsDevelopersWebhookDetail>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText(
'We will send POST requests to this endpoint for every new event',
await canvas.findByDisplayValue(
'https://api.slackbot.io/webhooks/twenty',
undefined,
{ timeout: 10000 },
{
timeout: 3000,
},
);
await canvas.findByDisplayValue('Slack notifications for lead updates');
const allObjectsLabels = await canvas.findAllByText('All Objects');
expect(allObjectsLabels).toHaveLength(2);
await canvas.findByText('Created');
await canvas.findByText('Updated');
await canvas.findByText('Delete this webhook');
},
};

View File

@ -1,19 +1,13 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { DateTime } from 'luxon';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput';
import { ApiKeyNameInput } from '@/settings/developers/components/ApiKeyNameInput';
import { apiKeyTokenFamilyState } from '@/settings/developers/states/apiKeyTokenFamilyState';
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
import { computeNewExpirationDate } from '@/settings/developers/utils/computeNewExpirationDate';
import { formatExpiration } from '@/settings/developers/utils/formatExpiration';
import { SettingsPath } from '@/types/SettingsPath';
@ -23,10 +17,16 @@ import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModa
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Trans, useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { H2Title, IconRepeat, IconTrash } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useGenerateApiKeyTokenMutation } from '~/generated-metadata/graphql';
import {
useCreateApiKeyMutation,
useGenerateApiKeyTokenMutation,
useGetApiKeyQuery,
useRevokeApiKeyMutation,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
@ -67,30 +67,34 @@ export const SettingsDevelopersApiKeyDetail = () => {
);
const [generateOneApiKeyToken] = useGenerateApiKeyTokenMutation();
const { createOneRecord: createOneApiKey } = useCreateOneRecord<ApiKey>({
objectNameSingular: CoreObjectNameSingular.ApiKey,
});
const { updateOneRecord: updateApiKey } = useUpdateOneRecord<ApiKey>({
objectNameSingular: CoreObjectNameSingular.ApiKey,
});
const [apiKeyName, setApiKeyName] = useState('');
const { record: apiKeyData, loading } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.ApiKey,
objectRecordId: apiKeyId,
onCompleted: (record) => {
setApiKeyName(record.name);
const [createApiKey] = useCreateApiKeyMutation();
const [revokeApiKey] = useRevokeApiKeyMutation();
const { data: apiKeyData } = useGetApiKeyQuery({
variables: {
input: {
id: apiKeyId,
},
},
onCompleted: (data) => {
if (isDefined(data?.apiKey)) {
setApiKeyName(data.apiKey.name);
}
},
});
const apiKey = apiKeyData?.apiKey;
const [apiKeyName, setApiKeyName] = useState('');
const deleteIntegration = async (redirect = true) => {
setIsLoading(true);
try {
await updateApiKey?.({
idToUpdate: apiKeyId,
updateOneRecordInput: { revokedAt: DateTime.now().toString() },
await revokeApiKey({
variables: {
input: {
id: apiKeyId,
},
},
});
if (redirect) {
navigate(SettingsPath.APIs);
@ -106,11 +110,17 @@ export const SettingsDevelopersApiKeyDetail = () => {
name: string,
newExpiresAt: string | null,
) => {
const newApiKey = await createOneApiKey?.({
name: name,
expiresAt: newExpiresAt ?? '',
const { data: newApiKeyData } = await createApiKey({
variables: {
input: {
name: name,
expiresAt: newExpiresAt ?? '',
},
},
});
const newApiKey = newApiKeyData?.createApiKey;
if (!newApiKey) {
return;
}
@ -130,18 +140,18 @@ export const SettingsDevelopersApiKeyDetail = () => {
const regenerateApiKey = async () => {
setIsLoading(true);
try {
if (isNonEmptyString(apiKeyData?.name)) {
if (isNonEmptyString(apiKey?.name)) {
const newExpiresAt = computeNewExpirationDate(
apiKeyData?.expiresAt,
apiKeyData?.createdAt,
apiKey?.expiresAt,
apiKey?.createdAt,
);
const apiKey = await createIntegration(apiKeyData?.name, newExpiresAt);
const newApiKey = await createIntegration(apiKey?.name, newExpiresAt);
await deleteIntegration(false);
if (isNonEmptyString(apiKey?.token)) {
setApiKeyTokenCallback(apiKey.id, apiKey.token);
if (isNonEmptyString(newApiKey?.token)) {
setApiKeyTokenCallback(newApiKey.id, newApiKey.token);
navigate(SettingsPath.ApiKeyDetail, {
apiKeyId: apiKey.id,
apiKeyId: newApiKey.id,
});
}
}
@ -158,9 +168,9 @@ export const SettingsDevelopersApiKeyDetail = () => {
return (
<>
{apiKeyData?.name && (
{apiKey?.name && (
<SubMenuTopBarContainer
title={apiKeyData?.name}
title={apiKey?.name}
links={[
{
children: t`Workspace`,
@ -196,11 +206,7 @@ export const SettingsDevelopersApiKeyDetail = () => {
onClick={() => openModal(REGENERATE_API_KEY_MODAL_ID)}
/>
<StyledInfo>
{formatExpiration(
apiKeyData?.expiresAt || '',
true,
false,
)}
{formatExpiration(apiKey?.expiresAt || '', true, false)}
</StyledInfo>
</StyledInputContainer>
</>
@ -210,8 +216,8 @@ export const SettingsDevelopersApiKeyDetail = () => {
<H2Title title={t`Name`} description={t`Name of your API key`} />
<ApiKeyNameInput
apiKeyName={apiKeyName}
apiKeyId={apiKeyData?.id}
disabled={loading}
apiKeyId={apiKey?.id}
disabled={isLoading}
onNameUpdate={setApiKeyName}
/>
</Section>
@ -221,13 +227,9 @@ export const SettingsDevelopersApiKeyDetail = () => {
description={t`When the key will be disabled`}
/>
<TextInput
instanceId={`api-key-expiration-${apiKeyData?.id}`}
instanceId={`api-key-expiration-${apiKey?.id}`}
placeholder={t`E.g. backoffice integration`}
value={formatExpiration(
apiKeyData?.expiresAt || '',
true,
false,
)}
value={formatExpiration(apiKey?.expiresAt || '', true, false)}
disabled
fullWidth
/>

View File

@ -1,13 +1,10 @@
import { DateTime } from 'luxon';
import { useState } from 'react';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { EXPIRATION_DATES } from '@/settings/developers/constants/ExpirationDates';
import { apiKeyTokenFamilyState } from '@/settings/developers/states/apiKeyTokenFamilyState';
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
import { SettingsPath } from '@/types/SettingsPath';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
@ -18,7 +15,10 @@ import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { useGenerateApiKeyTokenMutation } from '~/generated-metadata/graphql';
import {
useCreateApiKeyMutation,
useGenerateApiKeyTokenMutation,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
@ -34,9 +34,7 @@ export const SettingsDevelopersApiKeysNew = () => {
name: '',
});
const { createOneRecord: createOneApiKey } = useCreateOneRecord<ApiKey>({
objectNameSingular: CoreObjectNameSingular.ApiKey,
});
const [createApiKey] = useCreateApiKeyMutation();
const setApiKeyTokenCallback = useRecoilCallback(
({ set }) =>
@ -51,11 +49,17 @@ export const SettingsDevelopersApiKeysNew = () => {
.plus({ days: formValues.expirationDate ?? 30 })
.toString();
const newApiKey = await createOneApiKey?.({
name: formValues.name,
expiresAt,
const { data: newApiKeyData } = await createApiKey({
variables: {
input: {
name: formValues.name,
expiresAt,
},
},
});
const newApiKey = newApiKeyData?.createApiKey;
if (!newApiKey) {
return;
}
@ -77,7 +81,7 @@ export const SettingsDevelopersApiKeysNew = () => {
});
}
};
const canSave = !!formValues.name && createOneApiKey;
const canSave = !!formValues.name && createApiKey;
return (
<SubMenuTopBarContainer
title={t`New key`}