From e8a1d0d6d5cfccbcf1e93797eb82742e61a65399 Mon Sep 17 00:00:00 2001 From: martmull Date: Thu, 16 Nov 2023 18:14:04 +0100 Subject: [PATCH] Remove api keys from old world (#2548) * Use apiKeyV2 for getApiKeys * Use apiKeyV2 for createApiKey * Use apiKeyV2 for getApiKey * Use apiKeyV2 to deleteapikey * Filter null revokedAt -> not working * Use apiKeyV2 to regenerate * Fix default values injected * Remove useless stuff * Fix type --- front/src/generated/graphql.tsx | 46 ++++++++++++ .../hooks/useCreateOneObjectRecord.ts | 13 +--- .../mutations/generateApiKeyV2Token.ts | 9 +++ .../developers/utils/format-expiration.ts | 6 +- .../SettingsDevelopersApiKeyDetail.tsx | 71 +++++++++---------- .../api-keys/SettingsDevelopersApiKeys.tsx | 21 ++++-- .../api-keys/SettingsDevelopersApiKeysNew.tsx | 46 ++++++------ server/src/core/api-key/api-key.resolver.ts | 15 ++++ server/src/core/api-key/api-key.service.ts | 28 ++++++++ 9 files changed, 179 insertions(+), 76 deletions(-) create mode 100644 front/src/modules/settings/developers/graphql/mutations/generateApiKeyV2Token.ts diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 0cc22000f..2098c8b44 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -1447,6 +1447,7 @@ export type Mutation = { deleteUserAccount: User; deleteUserV2: UserV2; deleteWorkspaceMember: WorkspaceMember; + generateApiKeyV2Token: ApiKeyToken; impersonate: Verify; renewToken: AuthTokens; revokeOneApiKey: ApiKey; @@ -1602,6 +1603,11 @@ export type MutationDeleteWorkspaceMemberArgs = { }; +export type MutationGenerateApiKeyV2TokenArgs = { + data: ApiKeyCreateInput; +}; + + export type MutationImpersonateArgs = { userId: Scalars['String']; }; @@ -3641,6 +3647,13 @@ export type DeleteOneApiKeyMutationVariables = Exact<{ export type DeleteOneApiKeyMutation = { __typename?: 'Mutation', revokeOneApiKey: { __typename?: 'ApiKey', id: string } }; +export type GenerateOneApiKeyTokenMutationVariables = Exact<{ + data: ApiKeyCreateInput; +}>; + + +export type GenerateOneApiKeyTokenMutation = { __typename?: 'Mutation', generateApiKeyV2Token: { __typename?: 'ApiKeyToken', token: string } }; + export type InsertOneApiKeyMutationVariables = Exact<{ data: ApiKeyCreateInput; }>; @@ -5650,6 +5663,39 @@ export function useDeleteOneApiKeyMutation(baseOptions?: Apollo.MutationHookOpti export type DeleteOneApiKeyMutationHookResult = ReturnType; export type DeleteOneApiKeyMutationResult = Apollo.MutationResult; export type DeleteOneApiKeyMutationOptions = Apollo.BaseMutationOptions; +export const GenerateOneApiKeyTokenDocument = gql` + mutation GenerateOneApiKeyToken($data: ApiKeyCreateInput!) { + generateApiKeyV2Token(data: $data) { + token + } +} + `; +export type GenerateOneApiKeyTokenMutationFn = Apollo.MutationFunction; + +/** + * __useGenerateOneApiKeyTokenMutation__ + * + * To run a mutation, you first call `useGenerateOneApiKeyTokenMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useGenerateOneApiKeyTokenMutation` 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 [generateOneApiKeyTokenMutation, { data, loading, error }] = useGenerateOneApiKeyTokenMutation({ + * variables: { + * data: // value for 'data' + * }, + * }); + */ +export function useGenerateOneApiKeyTokenMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(GenerateOneApiKeyTokenDocument, options); + } +export type GenerateOneApiKeyTokenMutationHookResult = ReturnType; +export type GenerateOneApiKeyTokenMutationResult = Apollo.MutationResult; +export type GenerateOneApiKeyTokenMutationOptions = Apollo.BaseMutationOptions; export const InsertOneApiKeyDocument = gql` mutation InsertOneApiKey($data: ApiKeyCreateInput!) { createOneApiKey(data: $data) { diff --git a/front/src/modules/object-record/hooks/useCreateOneObjectRecord.ts b/front/src/modules/object-record/hooks/useCreateOneObjectRecord.ts index 4010a1ffc..77fd79c69 100644 --- a/front/src/modules/object-record/hooks/useCreateOneObjectRecord.ts +++ b/front/src/modules/object-record/hooks/useCreateOneObjectRecord.ts @@ -1,4 +1,5 @@ import { useMutation } from '@apollo/client'; +import { v4 } from 'uuid'; import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; import { useFindOneObjectMetadataItem } from '@/object-metadata/hooks/useFindOneObjectMetadataItem'; @@ -41,16 +42,7 @@ export const useCreateOneObjectRecord = ({ ? async (input: Record) => { const createdObject = await mutate({ variables: { - input: { - ...foundObjectMetadataItem.fields.reduce( - (result, field) => ({ - ...result, - [field.name]: defaultFieldValues[field.type], - }), - {}, - ), - ...input, - }, + input: { ...input, id: v4() }, }, }); @@ -60,6 +52,7 @@ export const useCreateOneObjectRecord = ({ `create${capitalize(foundObjectMetadataItem.nameSingular)}` ], ); + return createdObject.data; } : undefined; diff --git a/front/src/modules/settings/developers/graphql/mutations/generateApiKeyV2Token.ts b/front/src/modules/settings/developers/graphql/mutations/generateApiKeyV2Token.ts new file mode 100644 index 000000000..2a4a49ede --- /dev/null +++ b/front/src/modules/settings/developers/graphql/mutations/generateApiKeyV2Token.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const GENERATE_ONE_API_KEY_TOKEN = gql` + mutation GenerateOneApiKeyToken($data: ApiKeyCreateInput!) { + generateApiKeyV2Token(data: $data) { + token + } + } +`; diff --git a/front/src/modules/settings/developers/utils/format-expiration.ts b/front/src/modules/settings/developers/utils/format-expiration.ts index ecdd62e1d..de85745dc 100644 --- a/front/src/modules/settings/developers/utils/format-expiration.ts +++ b/front/src/modules/settings/developers/utils/format-expiration.ts @@ -1,5 +1,5 @@ import { ApiFieldItem } from '@/settings/developers/types/ApiFieldItem'; -import { GetApiKeysQuery } from '~/generated/graphql'; +import { ApiKey } from '~/generated/graphql'; import { beautifyDateDiff } from '~/utils/date-utils'; export const formatExpiration = ( @@ -18,9 +18,9 @@ export const formatExpiration = ( }; export const formatExpirations = ( - apiKeysQuery: GetApiKeysQuery, + apiKeys: Array>, ): ApiFieldItem[] => { - return apiKeysQuery.findManyApiKey.map(({ id, name, expiresAt }) => { + return apiKeys.map(({ id, name, expiresAt }) => { return { id, name, diff --git a/front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx b/front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx index 9dd5c5be8..0ba89dd5c 100644 --- a/front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx +++ b/front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx @@ -1,9 +1,12 @@ import { useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import styled from '@emotion/styled'; +import { DateTime } from 'luxon'; import { useRecoilState } from 'recoil'; -import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; +import { useCreateOneObjectRecord } from '@/object-record/hooks/useCreateOneObjectRecord'; +import { useFindOneObjectRecord } from '@/object-record/hooks/useFindOneObjectRecord'; +import { useUpdateOneObjectRecord } from '@/object-record/hooks/useUpdateOneObjectRecord'; import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput'; @@ -18,11 +21,7 @@ import { TextInput } from '@/ui/input/components/TextInput'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { Section } from '@/ui/layout/section/components/Section'; import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; -import { - useDeleteOneApiKeyMutation, - useGetApiKeyQuery, - useInsertOneApiKeyMutation, -} from '~/generated/graphql'; +import { useGenerateOneApiKeyTokenMutation } from '~/generated/graphql'; const StyledInfo = styled.span` color: ${({ theme }) => theme.font.color.light}; @@ -41,28 +40,29 @@ const StyledInputContainer = styled.div` export const SettingsDevelopersApiKeyDetail = () => { const navigate = useNavigate(); const { apiKeyId = '' } = useParams(); - const { triggerOptimisticEffects } = useOptimisticEffect('ApiKeyV2'); const setGeneratedApi = useGeneratedApiKeys(); const [generatedApiKey] = useRecoilState( generatedApiKeyFamilyState(apiKeyId), ); - const [deleteApiKey] = useDeleteOneApiKeyMutation(); - const [insertOneApiKey] = useInsertOneApiKeyMutation(); - const apiKeyData = useGetApiKeyQuery({ - variables: { - apiKeyId, - }, - }).data?.findManyApiKey[0]; + const [generateOneApiKeyToken] = useGenerateOneApiKeyTokenMutation(); + const { createOneObject: createOneApiKey } = useCreateOneObjectRecord({ + objectNamePlural: 'apiKeysV2', + }); + const { updateOneObject: updateApiKey } = useUpdateOneObjectRecord({ + objectNamePlural: 'apiKeysV2', + }); + + const { object: apiKeyData } = useFindOneObjectRecord({ + objectNameSingular: 'apiKeyV2', + objectMetadataId: apiKeyId, + }); const deleteIntegration = async (redirect = true) => { - await deleteApiKey({ - variables: { apiKeyId }, - update: (cache) => - cache.evict({ - id: cache.identify({ __typename: 'ApiKey', id: apiKeyId }), - }), + await updateApiKey?.({ + idToUpdate: apiKeyId, + input: { revokedAt: DateTime.now().toString() }, }); if (redirect) { navigate('/settings/developers/api-keys'); @@ -73,19 +73,23 @@ export const SettingsDevelopersApiKeyDetail = () => { name: string, newExpiresAt: string | null, ) => { - return await insertOneApiKey({ + const newApiKey = await createOneApiKey?.({ + name: name, + expiresAt: newExpiresAt, + }); + const tokenData = await generateOneApiKeyToken({ variables: { data: { - name: name, - expiresAt: newExpiresAt, + id: newApiKey.createApiKeyV2.id, + expiresAt: newApiKey.createApiKeyV2.expiresAt, + name: newApiKey.createApiKeyV2.name, // TODO update typing to remove useless name param here }, }, - update: (_cache, { data }) => { - if (data?.createOneApiKey) { - triggerOptimisticEffects('ApiKey', [data?.createOneApiKey]); - } - }, }); + return { + id: newApiKey.createApiKeyV2.id, + token: tokenData.data?.generateApiKeyV2Token.token, + }; }; const regenerateApiKey = async () => { @@ -96,14 +100,9 @@ export const SettingsDevelopersApiKeyDetail = () => { ); const apiKey = await createIntegration(apiKeyData.name, newExpiresAt); await deleteIntegration(false); - if (apiKey.data?.createOneApiKey) { - setGeneratedApi( - apiKey.data.createOneApiKey.id, - apiKey.data.createOneApiKey.token, - ); - navigate( - `/settings/developers/api-keys/${apiKey.data.createOneApiKey.id}`, - ); + if (apiKey.token) { + setGeneratedApi(apiKey.id, apiKey.token); + navigate(`/settings/developers/api-keys/${apiKey.id}`); } } }; diff --git a/front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeys.tsx b/front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeys.tsx index ef3598def..ca09620dc 100644 --- a/front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeys.tsx +++ b/front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeys.tsx @@ -1,10 +1,13 @@ +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import styled from '@emotion/styled'; import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; +import { useFindManyObjectRecords } from '@/object-record/hooks/useFindManyObjectRecords'; import { objectSettingsWidth } from '@/settings/data-model/constants/objectSettings'; import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow'; import { getApiKeysOptimisticEffectDefinition } from '@/settings/developers/optimistic-effect-definitions/getApiKeysOptimisticEffectDefinition'; +import { ApiFieldItem } from '@/settings/developers/types/ApiFieldItem'; import { formatExpirations } from '@/settings/developers/utils/format-expiration'; import { IconPlus, IconSettings } from '@/ui/display/icon'; import { H1Title } from '@/ui/display/typography/components/H1Title'; @@ -14,7 +17,6 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer' import { Table } from '@/ui/layout/table/components/Table'; import { TableHeader } from '@/ui/layout/table/components/TableHeader'; import { TableRow } from '@/ui/layout/table/components/TableRow'; -import { useGetApiKeysQuery } from '~/generated/graphql'; const StyledContainer = styled.div` height: fit-content; @@ -40,15 +42,26 @@ const StyledH1Title = styled(H1Title)` export const SettingsDevelopersApiKeys = () => { const navigate = useNavigate(); const { registerOptimisticEffect } = useOptimisticEffect('ApiKeyV2'); - const apiKeysQuery = useGetApiKeysQuery({ - onCompleted: () => { + const [apiKeys, setApiKeys] = useState>([]); + useFindManyObjectRecords({ + objectNamePlural: 'apiKeysV2', + /*filter: { revokedAt: { eq: null } },*/ + onCompleted: (data) => { + setApiKeys( + formatExpirations( + data.edges.map((apiKey) => ({ + id: apiKey.node.id, + name: apiKey.node.name, + expiresAt: apiKey.node.expiresAt, + })), + ), + ); registerOptimisticEffect({ variables: {}, definition: getApiKeysOptimisticEffectDefinition, }); }, }); - const apiKeys = apiKeysQuery.data ? formatExpirations(apiKeysQuery.data) : []; return ( diff --git a/front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx b/front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx index c8c494745..5ab2b30b2 100644 --- a/front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx +++ b/front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { DateTime } from 'luxon'; -import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; +import { useCreateOneObjectRecord } from '@/object-record/hooks/useCreateOneObjectRecord'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; @@ -15,11 +15,10 @@ import { TextInput } from '@/ui/input/components/TextInput'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { Section } from '@/ui/layout/section/components/Section'; import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; -import { useInsertOneApiKeyMutation } from '~/generated/graphql'; +import { useGenerateOneApiKeyTokenMutation } from '~/generated/graphql'; export const SettingsDevelopersApiKeysNew = () => { - const [insertOneApiKey] = useInsertOneApiKeyMutation(); - const { triggerOptimisticEffects } = useOptimisticEffect('ApiKeyV2'); + const [generateOneApiKeyToken] = useGenerateOneApiKeyTokenMutation(); const navigate = useNavigate(); const setGeneratedApi = useGeneratedApiKeys(); const [formValues, setFormValues] = useState<{ @@ -29,35 +28,36 @@ export const SettingsDevelopersApiKeysNew = () => { expirationDate: ExpirationDates[0].value, name: '', }); + + const { createOneObject: createOneApiKey } = useCreateOneObjectRecord({ + objectNamePlural: 'apiKeysV2', + }); const onSave = async () => { - const apiKey = await insertOneApiKey({ + const expiresAt = formValues.expirationDate + ? DateTime.now().plus({ days: formValues.expirationDate }).toString() + : null; + const newApiKey = await createOneApiKey?.({ + name: formValues.name, + expiresAt, + }); + const tokenData = await generateOneApiKeyToken({ variables: { data: { - name: formValues.name, - expiresAt: formValues.expirationDate - ? DateTime.now() - .plus({ days: formValues.expirationDate }) - .toString() - : null, + id: newApiKey.createApiKeyV2.id, + expiresAt: newApiKey.createApiKeyV2.expiresAt, + name: newApiKey.createApiKeyV2.name, // TODO update typing to remove useless name param here }, }, - update: (_cache, { data }) => { - if (data?.createOneApiKey) { - triggerOptimisticEffects('ApiKey', [data?.createOneApiKey]); - } - }, }); - if (apiKey.data?.createOneApiKey) { + if (tokenData.data?.generateApiKeyV2Token) { setGeneratedApi( - apiKey.data.createOneApiKey.id, - apiKey.data.createOneApiKey.token, - ); - navigate( - `/settings/developers/api-keys/${apiKey.data.createOneApiKey.id}`, + newApiKey.createApiKeyV2.id, + tokenData.data.generateApiKeyV2Token.token, ); + navigate(`/settings/developers/api-keys/${newApiKey.createApiKeyV2.id}`); } }; - const canSave = !!formValues.name; + const canSave = !!formValues.name && createOneApiKey; return ( diff --git a/server/src/core/api-key/api-key.resolver.ts b/server/src/core/api-key/api-key.resolver.ts index a4cec079c..4f633e144 100644 --- a/server/src/core/api-key/api-key.resolver.ts +++ b/server/src/core/api-key/api-key.resolver.ts @@ -42,6 +42,21 @@ export class ApiKeyResolver { ); } + @Mutation(() => ApiKeyToken) + @UseGuards(AbilityGuard) + @CheckAbilities(CreateApiKeyAbilityHandler) + async generateApiKeyV2Token( + @Args() + args: CreateOneApiKeyArgs, + @AuthWorkspace() { id: workspaceId }: Workspace, + ): Promise | undefined> { + return await this.apiKeyService.generateApiKeyV2Token( + workspaceId, + args.data.id, + args.data.expiresAt, + ); + } + @Mutation(() => ApiKey) @UseGuards(AbilityGuard) @CheckAbilities(UpdateApiKeyAbilityHandler) diff --git a/server/src/core/api-key/api-key.service.ts b/server/src/core/api-key/api-key.service.ts index 64c133c7e..d3a7de114 100644 --- a/server/src/core/api-key/api-key.service.ts +++ b/server/src/core/api-key/api-key.service.ts @@ -21,6 +21,34 @@ export class ApiKeyService { update = this.prismaService.client.apiKey.update; delete = this.prismaService.client.apiKey.delete; + async generateApiKeyV2Token( + workspaceId: string, + apiKeyId?: string, + expiresAt?: Date | string, + ): Promise | undefined> { + if (!apiKeyId) { + return; + } + const jwtPayload = { + sub: workspaceId, + }; + const secret = this.environmentService.getAccessTokenSecret(); + let expiresIn: string | number; + if (expiresAt) { + expiresIn = Math.floor( + (new Date(expiresAt).getTime() - new Date().getTime()) / 1000, + ); + } else { + expiresIn = this.environmentService.getApiTokenExpiresIn(); + } + const token = this.jwtService.sign(jwtPayload, { + secret, + expiresIn, + jwtid: apiKeyId, + }); + return { token }; + } + async generateApiKeyToken( workspaceId: string, name: string,