From d61511262e1148e202d718d306541839601cdcf2 Mon Sep 17 00:00:00 2001 From: martmull Date: Tue, 24 Oct 2023 16:14:54 +0200 Subject: [PATCH] 2060 create a new api key (#2206) * Add folder for api settings * Init create api key page * Update create api key page * Implement api call to create apiKey * Add create api key mutation * Get id when creating apiKey * Display created Api Key * Add delete api key button * Remove button from InputText * Update stuff * Add test for ApiDetail * Fix type * Use recoil instead of router state * Remane route paths * Remove online return * Move and test date util * Remove useless Component * Rename ApiKeys paths * Rename ApiKeys files * Add input text info testing * Rename hooks to webhooks * Remove console error * Add tests to reach minimum coverage --- .../docs/contributor/server/others/zapier.mdx | 2 +- front/.eslintrc.js | 2 +- front/src/App.tsx | 24 +- front/src/generated-metadata/graphql.ts | 10 + front/src/generated/graphql.tsx | 216 +++++++++++++++++- .../board/components/CompanyBoard.tsx | 2 +- .../settings/components/SettingsNavbar.tsx | 4 +- .../developers/components/ApiKeyInput.tsx | 51 +++++ ...x => SettingsApiKeysFieldItemTableRow.tsx} | 2 +- .../__stories__/ApiKeyInput.stories.tsx | 19 ++ ...ttingsApiKeysFieldItemTableRow.stories.tsx | 23 ++ .../developers/constants/expirationDates.ts | 11 + .../graphql/mutations/deleteOneApiKey.ts | 9 + .../graphql/mutations/insertOneApiKey.ts | 11 + .../developers/graphql/queries/getApiKey.ts | 11 + .../developers/states/generatedApiKeyState.ts | 6 + .../profile/components/NameFields.tsx | 2 +- .../workspace/components/NameField.tsx | 2 +- front/src/modules/types/AppPath.ts | 1 + front/src/modules/types/SettingsPath.ts | 4 +- .../ui/data/field/contexts/FieldContext.ts | 2 +- .../modules/ui/input/components/Select.tsx | 4 +- .../modules/ui/input/components/TextInput.tsx | 11 +- .../components/__stories__/Select.stories.tsx | 4 +- .../__stories__/TextInput.stories.tsx | 4 + .../boardColumnTotalsFamilySelector.ts | 2 +- .../useListenClickOutsideArrayOfRef.test.tsx | 2 +- front/src/pages/auth/CreateProfile.tsx | 2 +- .../__stories__/SettingsApi.stories.tsx | 25 -- .../SettingsDevelopersApiKeyDetail.tsx | 76 ++++++ .../api-keys/SettingsDevelopersApiKeys.tsx} | 8 +- .../api-keys/SettingsDevelopersApiKeysNew.tsx | 100 ++++++++ .../SettingsDevelopersApiKeys.stories.tsx | 29 +++ ...ettingsDevelopersApiKeysDetail.stories.tsx | 32 +++ .../SettingsDevelopersApiKeysNew.stories.tsx | 29 +++ .../src/testing/decorators/PageDecorator.tsx | 26 ++- front/src/testing/graphqlMocks.ts | 9 + front/src/testing/mock-data/api-keys.ts | 18 ++ front/src/utils/__tests__/date-utils.test.ts | 45 ++++ front/src/utils/date-utils.ts | 14 ++ packages/twenty-zapier/src/authentication.ts | 2 +- .../src/test/triggers/company.test.ts | 12 +- .../twenty-zapier/src/triggers/company.ts | 10 +- server/src/ability/ability.factory.ts | 12 +- server/src/ability/ability.module.ts | 22 +- ...handler.ts => web-hook.ability-handler.ts} | 16 +- server/src/core/api-key/api-key.resolver.ts | 6 +- server/src/core/api-key/api-key.service.ts | 5 +- server/src/core/auth/dto/token.entity.ts | 12 + server/src/core/core.module.ts | 7 +- .../web-hook.module.ts} | 7 +- .../web-hook.resolver.ts} | 52 ++--- .../migration.sql | 27 +++ server/src/database/schema.prisma | 6 +- .../utils/prisma-select/model-select-map.ts | 2 +- 55 files changed, 919 insertions(+), 133 deletions(-) create mode 100644 front/src/modules/settings/developers/components/ApiKeyInput.tsx rename front/src/modules/settings/developers/components/{SettingsApisFieldItemTableRow.tsx => SettingsApiKeysFieldItemTableRow.tsx} (96%) create mode 100644 front/src/modules/settings/developers/components/__stories__/ApiKeyInput.stories.tsx create mode 100644 front/src/modules/settings/developers/components/__stories__/SettingsApiKeysFieldItemTableRow.stories.tsx create mode 100644 front/src/modules/settings/developers/constants/expirationDates.ts create mode 100644 front/src/modules/settings/developers/graphql/mutations/deleteOneApiKey.ts create mode 100644 front/src/modules/settings/developers/graphql/mutations/insertOneApiKey.ts create mode 100644 front/src/modules/settings/developers/graphql/queries/getApiKey.ts create mode 100644 front/src/modules/settings/developers/states/generatedApiKeyState.ts delete mode 100644 front/src/pages/settings/__stories__/SettingsApi.stories.tsx create mode 100644 front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx rename front/src/pages/settings/{SettingsApis.tsx => developers/api-keys/SettingsDevelopersApiKeys.tsx} (89%) create mode 100644 front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx create mode 100644 front/src/pages/settings/developers/api-keys/__stories__/SettingsDevelopersApiKeys.stories.tsx create mode 100644 front/src/pages/settings/developers/api-keys/__stories__/SettingsDevelopersApiKeysDetail.stories.tsx create mode 100644 front/src/pages/settings/developers/api-keys/__stories__/SettingsDevelopersApiKeysNew.stories.tsx create mode 100644 front/src/testing/mock-data/api-keys.ts rename server/src/ability/handlers/{hook.ability-handler.ts => web-hook.ability-handler.ts} (73%) rename server/src/core/{hook/hook.module.ts => web-hook/web-hook.module.ts} (62%) rename server/src/core/{hook/hook.resolver.ts => web-hook/web-hook.resolver.ts} (52%) create mode 100644 server/src/database/migrations/20231024123425_rename_hooks_table_to_web_hooks/migration.sql diff --git a/docs/docs/contributor/server/others/zapier.mdx b/docs/docs/contributor/server/others/zapier.mdx index 7d5dbf5e6..6ae4bc259 100644 --- a/docs/docs/contributor/server/others/zapier.mdx +++ b/docs/docs/contributor/server/others/zapier.mdx @@ -37,7 +37,7 @@ From the `packages/twenty-zapier` folder, run: ```bash cp .env.example .env ``` -Run the application locally, go to [http://localhost:3000/settings/apis](http://localhost:3000/settings/apis), and generate an API key. +Run the application locally, go to [http://localhost:3000/settings/developers/api-keys](http://localhost:3000/settings/developers/api-keys), and generate an API key. Replace the **YOUR_API_KEY** value in the `.env` file with the API key you just generated. diff --git a/front/.eslintrc.js b/front/.eslintrc.js index 9432bdbf4..9829bbfa8 100644 --- a/front/.eslintrc.js +++ b/front/.eslintrc.js @@ -107,7 +107,7 @@ module.exports = { 'message': 'Icon imports are only allowed for `@/ui/icon`', }, { - 'group': ['react-hotkeys-hook'], + 'group': ['react-hotkeys-web-hook'], "importNames": ["useHotkeys"], 'message': 'Please use the custom wrapper: `useScopedHotkeys`', }, diff --git a/front/src/App.tsx b/front/src/App.tsx index 205dea85d..5b21d7f98 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -21,6 +21,9 @@ import { SettingsNewObject } from '~/pages/settings/data-model/SettingsNewObject import { SettingsObjectDetail } from '~/pages/settings/data-model/SettingsObjectDetail'; import { SettingsObjectEdit } from '~/pages/settings/data-model/SettingsObjectEdit'; import { SettingsObjects } from '~/pages/settings/data-model/SettingsObjects'; +import { SettingsDevelopersApiKeyDetail } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail'; +import { SettingsDevelopersApiKeys } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeys'; +import { SettingsDevelopersApiKeysNew } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew'; import { SettingsExperience } from '~/pages/settings/SettingsExperience'; import { SettingsProfile } from '~/pages/settings/SettingsProfile'; import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace'; @@ -31,7 +34,6 @@ import { getPageTitleFromPath } from '~/utils/title-utils'; import { ObjectTablePage } from './modules/metadata/components/ObjectTablePage'; import { SettingsObjectNewFieldStep1 } from './pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep1'; import { SettingsObjectNewFieldStep2 } from './pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2'; -import { SettingsApis } from './pages/settings/SettingsApis'; export const App = () => { const { pathname } = useLocation(); @@ -97,7 +99,25 @@ export const App = () => { path={SettingsPath.NewObject} element={} /> - } /> + + } + /> + } + /> + } + /> + + } + /> } diff --git a/front/src/generated-metadata/graphql.ts b/front/src/generated-metadata/graphql.ts index 794d888c7..19cdc4da7 100644 --- a/front/src/generated-metadata/graphql.ts +++ b/front/src/generated-metadata/graphql.ts @@ -760,6 +760,15 @@ export enum ViewType { Table = 'Table' } +export type WebHook = { + __typename?: 'WebHook'; + createdAt: Scalars['DateTime']['output']; + id: Scalars['ID']['output']; + operation: Scalars['String']['output']; + targetUrl: Scalars['String']['output']; + updatedAt: Scalars['DateTime']['output']; +}; + export type Workspace = { __typename?: 'Workspace'; Attachment?: Maybe>; @@ -783,6 +792,7 @@ export type Workspace = { viewFilters?: Maybe>; viewSorts?: Maybe>; views?: Maybe>; + webHooks?: Maybe>; workspaceMember?: Maybe>; }; diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 7ccb56bf9..54859b2f4 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -482,6 +482,13 @@ export enum ApiKeyScalarFieldEnum { WorkspaceId = 'workspaceId' } +export type ApiKeyToken = { + __typename?: 'ApiKeyToken'; + expiresAt: Scalars['DateTime']; + id: Scalars['String']; + token: Scalars['String']; +}; + export type ApiKeyUpdateManyWithoutWorkspaceNestedInput = { connect?: InputMaybe>; disconnect?: InputMaybe>; @@ -1392,7 +1399,7 @@ export type Mutation = { createManyViewFilter: AffectedRows; createManyViewSort: AffectedRows; createOneActivity: Activity; - createOneApiKey: AuthToken; + createOneApiKey: ApiKeyToken; createOneComment: Comment; createOneCompany: Company; createOneField: Field; @@ -1402,6 +1409,7 @@ export type Mutation = { createOnePipelineStage: PipelineStage; createOneView: View; createOneViewField: ViewField; + createOneWebHook: WebHook; deleteCurrentWorkspace: Workspace; deleteFavorite: Favorite; deleteManyActivities: AffectedRows; @@ -1415,6 +1423,7 @@ export type Mutation = { deleteOneObject: ObjectDeleteResponse; deleteOnePipelineStage: PipelineStage; deleteOneView: View; + deleteOneWebHook: WebHook; deleteUserAccount: User; deleteWorkspaceMember: WorkspaceMember; impersonate: Verify; @@ -1559,6 +1568,11 @@ export type MutationCreateOneViewFieldArgs = { }; +export type MutationCreateOneWebHookArgs = { + data: WebHookCreateInput; +}; + + export type MutationDeleteFavoriteArgs = { where: FavoriteWhereInput; }; @@ -1609,6 +1623,11 @@ export type MutationDeleteOneViewArgs = { }; +export type MutationDeleteOneWebHookArgs = { + where: WebHookWhereUniqueInput; +}; + + export type MutationDeleteWorkspaceMemberArgs = { where: WorkspaceMemberWhereUniqueInput; }; @@ -2523,6 +2542,7 @@ export type Query = { findManyViewField: Array; findManyViewFilter: Array; findManyViewSort: Array; + findManyWebHook: Array; findManyWorkspaceMember: Array; findUniqueCompany: Company; findUniquePerson: Person; @@ -2662,6 +2682,16 @@ export type QueryFindManyViewSortArgs = { }; +export type QueryFindManyWebHookArgs = { + cursor?: InputMaybe; + distinct?: InputMaybe>; + orderBy?: InputMaybe>; + skip?: InputMaybe; + take?: InputMaybe; + where?: InputMaybe; +}; + + export type QueryFindManyWorkspaceMemberArgs = { cursor?: InputMaybe; distinct?: InputMaybe>; @@ -3392,6 +3422,62 @@ export type ViewWhereUniqueInput = { id?: InputMaybe; }; +export type WebHook = { + __typename?: 'WebHook'; + createdAt: Scalars['DateTime']; + id: Scalars['ID']; + operation: Scalars['String']; + targetUrl: Scalars['String']; + updatedAt: Scalars['DateTime']; +}; + +export type WebHookCreateInput = { + createdAt?: InputMaybe; + id?: InputMaybe; + operation: Scalars['String']; + targetUrl: Scalars['String']; + updatedAt?: InputMaybe; +}; + +export type WebHookOrderByWithRelationInput = { + createdAt?: InputMaybe; + id?: InputMaybe; + operation?: InputMaybe; + targetUrl?: InputMaybe; + updatedAt?: InputMaybe; +}; + +export enum WebHookScalarFieldEnum { + CreatedAt = 'createdAt', + DeletedAt = 'deletedAt', + Id = 'id', + Operation = 'operation', + TargetUrl = 'targetUrl', + UpdatedAt = 'updatedAt', + WorkspaceId = 'workspaceId' +} + +export type WebHookUpdateManyWithoutWorkspaceNestedInput = { + connect?: InputMaybe>; + disconnect?: InputMaybe>; + set?: InputMaybe>; +}; + +export type WebHookWhereInput = { + AND?: InputMaybe>; + NOT?: InputMaybe>; + OR?: InputMaybe>; + createdAt?: InputMaybe; + id?: InputMaybe; + operation?: InputMaybe; + targetUrl?: InputMaybe; + updatedAt?: InputMaybe; +}; + +export type WebHookWhereUniqueInput = { + id?: InputMaybe; +}; + export type Workspace = { __typename?: 'Workspace'; Attachment?: Maybe>; @@ -3415,6 +3501,7 @@ export type Workspace = { viewFilters?: Maybe>; viewSorts?: Maybe>; views?: Maybe>; + webHooks?: Maybe>; workspaceMember?: Maybe>; }; @@ -3590,6 +3677,7 @@ export type WorkspaceUpdateInput = { viewFilters?: InputMaybe; viewSorts?: InputMaybe; views?: InputMaybe; + webHooks?: InputMaybe; workspaceMember?: InputMaybe; }; @@ -4118,6 +4206,27 @@ export type SearchUserQueryVariables = Exact<{ export type SearchUserQuery = { __typename?: 'Query', searchResults: Array<{ __typename?: 'User', avatarUrl?: string | null, id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null }> }; +export type DeleteOneApiKeyMutationVariables = Exact<{ + apiKeyId: Scalars['String']; +}>; + + +export type DeleteOneApiKeyMutation = { __typename?: 'Mutation', revokeOneApiKey: { __typename?: 'ApiKey', id: string } }; + +export type InsertOneApiKeyMutationVariables = Exact<{ + data: ApiKeyCreateInput; +}>; + + +export type InsertOneApiKeyMutation = { __typename?: 'Mutation', createOneApiKey: { __typename?: 'ApiKeyToken', id: string, token: string, expiresAt: string } }; + +export type GetApiKeyQueryVariables = Exact<{ + apiKeyId: Scalars['String']; +}>; + + +export type GetApiKeyQuery = { __typename?: 'Query', findManyApiKey: Array<{ __typename?: 'ApiKey', id: string, name: string, expiresAt?: string | null }> }; + export type UserFieldsFragmentFragment = { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null }; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; @@ -6771,6 +6880,111 @@ export function useSearchUserLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions export type SearchUserQueryHookResult = ReturnType; export type SearchUserLazyQueryHookResult = ReturnType; export type SearchUserQueryResult = Apollo.QueryResult; +export const DeleteOneApiKeyDocument = gql` + mutation DeleteOneApiKey($apiKeyId: String!) { + revokeOneApiKey(where: {id: $apiKeyId}) { + id + } +} + `; +export type DeleteOneApiKeyMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteOneApiKeyMutation__ + * + * To run a mutation, you first call `useDeleteOneApiKeyMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteOneApiKeyMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [deleteOneApiKeyMutation, { data, loading, error }] = useDeleteOneApiKeyMutation({ + * variables: { + * apiKeyId: // value for 'apiKeyId' + * }, + * }); + */ +export function useDeleteOneApiKeyMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteOneApiKeyDocument, options); + } +export type DeleteOneApiKeyMutationHookResult = ReturnType; +export type DeleteOneApiKeyMutationResult = Apollo.MutationResult; +export type DeleteOneApiKeyMutationOptions = Apollo.BaseMutationOptions; +export const InsertOneApiKeyDocument = gql` + mutation InsertOneApiKey($data: ApiKeyCreateInput!) { + createOneApiKey(data: $data) { + id + token + expiresAt + } +} + `; +export type InsertOneApiKeyMutationFn = Apollo.MutationFunction; + +/** + * __useInsertOneApiKeyMutation__ + * + * To run a mutation, you first call `useInsertOneApiKeyMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useInsertOneApiKeyMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [insertOneApiKeyMutation, { data, loading, error }] = useInsertOneApiKeyMutation({ + * variables: { + * data: // value for 'data' + * }, + * }); + */ +export function useInsertOneApiKeyMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(InsertOneApiKeyDocument, options); + } +export type InsertOneApiKeyMutationHookResult = ReturnType; +export type InsertOneApiKeyMutationResult = Apollo.MutationResult; +export type InsertOneApiKeyMutationOptions = Apollo.BaseMutationOptions; +export const GetApiKeyDocument = gql` + query GetApiKey($apiKeyId: String!) { + findManyApiKey(where: {id: {equals: $apiKeyId}}) { + id + name + expiresAt + } +} + `; + +/** + * __useGetApiKeyQuery__ + * + * To run a query within a React component, call `useGetApiKeyQuery` and pass it any options that fit your needs. + * When your component renders, `useGetApiKeyQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetApiKeyQuery({ + * variables: { + * apiKeyId: // value for 'apiKeyId' + * }, + * }); + */ +export function useGetApiKeyQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetApiKeyDocument, options); + } +export function useGetApiKeyLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetApiKeyDocument, options); + } +export type GetApiKeyQueryHookResult = ReturnType; +export type GetApiKeyLazyQueryHookResult = ReturnType; +export type GetApiKeyQueryResult = Apollo.QueryResult; export const DeleteUserAccountDocument = gql` mutation DeleteUserAccount { deleteUserAccount { diff --git a/front/src/modules/companies/board/components/CompanyBoard.tsx b/front/src/modules/companies/board/components/CompanyBoard.tsx index 717f308a2..f4aa76ac3 100644 --- a/front/src/modules/companies/board/components/CompanyBoard.tsx +++ b/front/src/modules/companies/board/components/CompanyBoard.tsx @@ -24,7 +24,7 @@ export const CompanyBoard = ({ onEditColumnTitle, }: CompanyBoardProps) => { // TODO: we can store objectId and fieldDefinitions in the ViewBarContext - // And then use the useBoardViews hook wherever we need it in the board + // And then use the useBoardViews web-hook wherever we need it in the board const { createView, deleteView, submitCurrentView, updateView } = useBoardViews({ objectId: 'company', diff --git a/front/src/modules/settings/components/SettingsNavbar.tsx b/front/src/modules/settings/components/SettingsNavbar.tsx index b65beb434..7bdba5db0 100644 --- a/front/src/modules/settings/components/SettingsNavbar.tsx +++ b/front/src/modules/settings/components/SettingsNavbar.tsx @@ -41,7 +41,7 @@ export const SettingsNavbar = () => { end: false, }); const isDevelopersSettingsActive = !!useMatch({ - path: useResolvedPath('/settings/api').pathname, + path: useResolvedPath('/settings/developers/api-keys').pathname, end: true, }); @@ -104,7 +104,7 @@ export const SettingsNavbar = () => { {isDevelopersSettingsEnabled && ( diff --git a/front/src/modules/settings/developers/components/ApiKeyInput.tsx b/front/src/modules/settings/developers/components/ApiKeyInput.tsx new file mode 100644 index 000000000..36639f35f --- /dev/null +++ b/front/src/modules/settings/developers/components/ApiKeyInput.tsx @@ -0,0 +1,51 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { IconCopy } from '@/ui/display/icon'; +import { useSnackBar } from '@/ui/feedback/snack-bar/hooks/useSnackBar'; +import { Button } from '@/ui/input/button/components/Button'; +import { TextInput } from '@/ui/input/components/TextInput'; +import { beautifyDateDiff } from '~/utils/date-utils'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: row; +`; + +const StyledLinkContainer = styled.div` + flex: 1; + margin-right: ${({ theme }) => theme.spacing(2)}; +`; + +type ApiKeyInputProps = { expiresAt?: string | null; apiKey: string }; + +export const ApiKeyInput = ({ expiresAt, apiKey }: ApiKeyInputProps) => { + const theme = useTheme(); + const computeInfo = () => { + if (!expiresAt) { + return ''; + } + return `This key will expire in ${beautifyDateDiff(expiresAt)}`; + }; + + const { enqueueSnackBar } = useSnackBar(); + return ( + + + + +