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

@ -87,6 +87,18 @@ export type ApiConfig = {
mutationMaximumAffectedRecords: Scalars['Float']; mutationMaximumAffectedRecords: Scalars['Float'];
}; };
export type ApiKey = {
__typename?: 'ApiKey';
createdAt: Scalars['DateTime'];
expiresAt: Scalars['DateTime'];
id: Scalars['UUID'];
name: Scalars['String'];
revokedAt?: Maybe<Scalars['DateTime']>;
updatedAt: Scalars['DateTime'];
workspace: Workspace;
workspaceId: Scalars['String'];
};
export type ApiKeyToken = { export type ApiKeyToken = {
__typename?: 'ApiKeyToken'; __typename?: 'ApiKeyToken';
token: Scalars['String']; token: Scalars['String'];
@ -435,6 +447,12 @@ export type ConnectionParametersOutput = {
username: Scalars['String']; username: Scalars['String'];
}; };
export type CreateApiKeyDto = {
expiresAt: Scalars['String'];
name: Scalars['String'];
revokedAt?: InputMaybe<Scalars['String']>;
};
export type CreateAppTokenInput = { export type CreateAppTokenInput = {
expiresAt: Scalars['DateTime']; expiresAt: Scalars['DateTime'];
}; };
@ -527,6 +545,13 @@ export type CreateServerlessFunctionInput = {
timeoutSeconds?: InputMaybe<Scalars['Float']>; timeoutSeconds?: InputMaybe<Scalars['Float']>;
}; };
export type CreateWebhookDto = {
description?: InputMaybe<Scalars['String']>;
operations: Array<Scalars['String']>;
secret?: InputMaybe<Scalars['String']>;
targetUrl: Scalars['String'];
};
export type CreateWorkflowVersionStepInput = { export type CreateWorkflowVersionStepInput = {
/** Next step ID */ /** Next step ID */
nextStepId?: InputMaybe<Scalars['String']>; nextStepId?: InputMaybe<Scalars['String']>;
@ -608,6 +633,10 @@ export type DeleteSsoOutput = {
identityProviderId: Scalars['String']; identityProviderId: Scalars['String'];
}; };
export type DeleteWebhookDto = {
id: Scalars['String'];
};
export type DeleteWorkflowVersionStepInput = { export type DeleteWorkflowVersionStepInput = {
/** Step to delete ID */ /** Step to delete ID */
stepId: Scalars['String']; stepId: Scalars['String'];
@ -683,9 +712,11 @@ export enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED', IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED' IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED'
} }
export type Field = { export type Field = {
@ -819,6 +850,10 @@ export type FullName = {
lastName: Scalars['String']; lastName: Scalars['String'];
}; };
export type GetApiKeyDto = {
id: Scalars['String'];
};
export type GetAuthorizationUrlForSsoInput = { export type GetAuthorizationUrlForSsoInput = {
identityProviderId: Scalars['String']; identityProviderId: Scalars['String'];
workspaceInviteHash?: InputMaybe<Scalars['String']>; workspaceInviteHash?: InputMaybe<Scalars['String']>;
@ -844,6 +879,10 @@ export type GetServerlessFunctionSourceCodeInput = {
version?: Scalars['String']; version?: Scalars['String'];
}; };
export type GetWebhookDto = {
id: Scalars['String'];
};
export enum HealthIndicatorId { export enum HealthIndicatorId {
app = 'app', app = 'app',
connectedAccount = 'connectedAccount', connectedAccount = 'connectedAccount',
@ -1015,6 +1054,7 @@ export type Mutation = {
checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>; checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>;
checkoutSession: BillingSessionOutput; checkoutSession: BillingSessionOutput;
computeStepOutputSchema: Scalars['JSON']; computeStepOutputSchema: Scalars['JSON'];
createApiKey: ApiKey;
createApprovedAccessDomain: ApprovedAccessDomain; createApprovedAccessDomain: ApprovedAccessDomain;
createDatabaseConfigVariable: Scalars['Boolean']; createDatabaseConfigVariable: Scalars['Boolean'];
createDraftFromWorkflowVersion: WorkflowVersion; createDraftFromWorkflowVersion: WorkflowVersion;
@ -1027,6 +1067,7 @@ export type Mutation = {
createOneRole: Role; createOneRole: Role;
createOneServerlessFunction: ServerlessFunction; createOneServerlessFunction: ServerlessFunction;
createSAMLIdentityProvider: SetupSsoOutput; createSAMLIdentityProvider: SetupSsoOutput;
createWebhook: Webhook;
createWorkflowVersionStep: WorkflowAction; createWorkflowVersionStep: WorkflowAction;
deactivateWorkflowVersion: Scalars['Boolean']; deactivateWorkflowVersion: Scalars['Boolean'];
deleteApprovedAccessDomain: Scalars['Boolean']; deleteApprovedAccessDomain: Scalars['Boolean'];
@ -1039,6 +1080,7 @@ export type Mutation = {
deleteOneServerlessFunction: ServerlessFunction; deleteOneServerlessFunction: ServerlessFunction;
deleteSSOIdentityProvider: DeleteSsoOutput; deleteSSOIdentityProvider: DeleteSsoOutput;
deleteUser: User; deleteUser: User;
deleteWebhook: Scalars['Boolean'];
deleteWorkflowVersionStep: WorkflowAction; deleteWorkflowVersionStep: WorkflowAction;
deleteWorkspaceInvitation: Scalars['String']; deleteWorkspaceInvitation: Scalars['String'];
disablePostgresProxy: PostgresCredentials; disablePostgresProxy: PostgresCredentials;
@ -1059,6 +1101,7 @@ export type Mutation = {
renewToken: AuthTokens; renewToken: AuthTokens;
resendEmailVerificationToken: ResendEmailVerificationTokenOutput; resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
resendWorkspaceInvitation: SendInvitationsOutput; resendWorkspaceInvitation: SendInvitationsOutput;
revokeApiKey?: Maybe<ApiKey>;
runWorkflowVersion: WorkflowRun; runWorkflowVersion: WorkflowRun;
saveImapSmtpCaldav: ImapSmtpCaldavConnectionSuccess; saveImapSmtpCaldav: ImapSmtpCaldavConnectionSuccess;
sendInvitations: SendInvitationsOutput; sendInvitations: SendInvitationsOutput;
@ -1075,6 +1118,7 @@ export type Mutation = {
syncRemoteTableSchemaChanges: RemoteTable; syncRemoteTableSchemaChanges: RemoteTable;
trackAnalytics: Analytics; trackAnalytics: Analytics;
unsyncRemoteTable: RemoteTable; unsyncRemoteTable: RemoteTable;
updateApiKey?: Maybe<ApiKey>;
updateDatabaseConfigVariable: Scalars['Boolean']; updateDatabaseConfigVariable: Scalars['Boolean'];
updateLabPublicFeatureFlag: FeatureFlagDto; updateLabPublicFeatureFlag: FeatureFlagDto;
updateOneAgent: Agent; updateOneAgent: Agent;
@ -1084,6 +1128,7 @@ export type Mutation = {
updateOneRole: Role; updateOneRole: Role;
updateOneServerlessFunction: ServerlessFunction; updateOneServerlessFunction: ServerlessFunction;
updatePasswordViaResetToken: InvalidatePassword; updatePasswordViaResetToken: InvalidatePassword;
updateWebhook?: Maybe<Webhook>;
updateWorkflowRunStep: WorkflowAction; updateWorkflowRunStep: WorkflowAction;
updateWorkflowVersionStep: WorkflowAction; updateWorkflowVersionStep: WorkflowAction;
updateWorkspace: Workspace; updateWorkspace: Workspace;
@ -1137,6 +1182,11 @@ export type MutationComputeStepOutputSchemaArgs = {
}; };
export type MutationCreateApiKeyArgs = {
input: CreateApiKeyDto;
};
export type MutationCreateApprovedAccessDomainArgs = { export type MutationCreateApprovedAccessDomainArgs = {
input: CreateApprovedAccessDomainInput; input: CreateApprovedAccessDomainInput;
}; };
@ -1201,6 +1251,11 @@ export type MutationCreateSamlIdentityProviderArgs = {
}; };
export type MutationCreateWebhookArgs = {
input: CreateWebhookDto;
};
export type MutationCreateWorkflowVersionStepArgs = { export type MutationCreateWorkflowVersionStepArgs = {
input: CreateWorkflowVersionStepInput; input: CreateWorkflowVersionStepInput;
}; };
@ -1251,6 +1306,11 @@ export type MutationDeleteSsoIdentityProviderArgs = {
}; };
export type MutationDeleteWebhookArgs = {
input: DeleteWebhookDto;
};
export type MutationDeleteWorkflowVersionStepArgs = { export type MutationDeleteWorkflowVersionStepArgs = {
input: DeleteWorkflowVersionStepInput; input: DeleteWorkflowVersionStepInput;
}; };
@ -1342,6 +1402,11 @@ export type MutationResendWorkspaceInvitationArgs = {
}; };
export type MutationRevokeApiKeyArgs = {
input: RevokeApiKeyDto;
};
export type MutationRunWorkflowVersionArgs = { export type MutationRunWorkflowVersionArgs = {
input: RunWorkflowVersionInput; input: RunWorkflowVersionInput;
}; };
@ -1415,6 +1480,11 @@ export type MutationUnsyncRemoteTableArgs = {
}; };
export type MutationUpdateApiKeyArgs = {
input: UpdateApiKeyDto;
};
export type MutationUpdateDatabaseConfigVariableArgs = { export type MutationUpdateDatabaseConfigVariableArgs = {
key: Scalars['String']; key: Scalars['String'];
value: Scalars['JSON']; value: Scalars['JSON'];
@ -1462,6 +1532,11 @@ export type MutationUpdatePasswordViaResetTokenArgs = {
}; };
export type MutationUpdateWebhookArgs = {
input: UpdateWebhookDto;
};
export type MutationUpdateWorkflowRunStepArgs = { export type MutationUpdateWorkflowRunStepArgs = {
input: UpdateWorkflowRunStepInput; input: UpdateWorkflowRunStepInput;
}; };
@ -1744,6 +1819,8 @@ export type PublishServerlessFunctionInput = {
export type Query = { export type Query = {
__typename?: 'Query'; __typename?: 'Query';
apiKey?: Maybe<ApiKey>;
apiKeys: Array<ApiKey>;
billingPortalSession: BillingSessionOutput; billingPortalSession: BillingSessionOutput;
checkUserExists: CheckUserExistOutput; checkUserExists: CheckUserExistOutput;
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
@ -1785,6 +1862,13 @@ export type Query = {
search: SearchResultConnection; search: SearchResultConnection;
validatePasswordResetToken: ValidatePasswordResetToken; validatePasswordResetToken: ValidatePasswordResetToken;
versionInfo: VersionInfo; versionInfo: VersionInfo;
webhook?: Maybe<Webhook>;
webhooks: Array<Webhook>;
};
export type QueryApiKeyArgs = {
input: GetApiKeyDto;
}; };
@ -1945,6 +2029,11 @@ export type QueryValidatePasswordResetTokenArgs = {
passwordResetToken: Scalars['String']; passwordResetToken: Scalars['String'];
}; };
export type QueryWebhookArgs = {
input: GetWebhookDto;
};
export type QueueMetricsData = { export type QueueMetricsData = {
__typename?: 'QueueMetricsData'; __typename?: 'QueueMetricsData';
data: Array<QueueMetricsSeries>; data: Array<QueueMetricsSeries>;
@ -2036,6 +2125,10 @@ export type ResendEmailVerificationTokenOutput = {
success: Scalars['Boolean']; success: Scalars['Boolean'];
}; };
export type RevokeApiKeyDto = {
id: Scalars['String'];
};
export type Role = { export type Role = {
__typename?: 'Role'; __typename?: 'Role';
canDestroyAllObjectRecords: Scalars['Boolean']; canDestroyAllObjectRecords: Scalars['Boolean'];
@ -2398,6 +2491,13 @@ export type UpdateAgentInput = {
responseFormat?: InputMaybe<Scalars['JSON']>; responseFormat?: InputMaybe<Scalars['JSON']>;
}; };
export type UpdateApiKeyDto = {
expiresAt?: InputMaybe<Scalars['String']>;
id: Scalars['String'];
name?: InputMaybe<Scalars['String']>;
revokedAt?: InputMaybe<Scalars['String']>;
};
export type UpdateFieldInput = { export type UpdateFieldInput = {
defaultValue?: InputMaybe<Scalars['JSON']>; defaultValue?: InputMaybe<Scalars['JSON']>;
description?: InputMaybe<Scalars['String']>; description?: InputMaybe<Scalars['String']>;
@ -2479,6 +2579,14 @@ export type UpdateServerlessFunctionInput = {
timeoutSeconds?: InputMaybe<Scalars['Float']>; timeoutSeconds?: InputMaybe<Scalars['Float']>;
}; };
export type UpdateWebhookDto = {
description?: InputMaybe<Scalars['String']>;
id: Scalars['String'];
operations?: InputMaybe<Array<Scalars['String']>>;
secret?: InputMaybe<Scalars['String']>;
targetUrl?: InputMaybe<Scalars['String']>;
};
export type UpdateWorkflowRunStepInput = { export type UpdateWorkflowRunStepInput = {
/** Step to update in JSON format */ /** Step to update in JSON format */
step: Scalars['JSON']; step: Scalars['JSON'];
@ -2621,6 +2729,20 @@ export type VersionInfo = {
latestVersion: Scalars['String']; latestVersion: Scalars['String'];
}; };
export type Webhook = {
__typename?: 'Webhook';
createdAt: Scalars['DateTime'];
deletedAt?: Maybe<Scalars['DateTime']>;
description?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
operations: Array<Scalars['String']>;
secret: Scalars['String'];
targetUrl: Scalars['String'];
updatedAt: Scalars['DateTime'];
workspace: Workspace;
workspaceId: Scalars['String'];
};
export type WorkerQueueMetrics = { export type WorkerQueueMetrics = {
__typename?: 'WorkerQueueMetrics'; __typename?: 'WorkerQueueMetrics';
active: Scalars['Float']; active: Scalars['Float'];
@ -3224,6 +3346,76 @@ export type GetSystemHealthStatusQueryVariables = Exact<{ [key: string]: never;
export type GetSystemHealthStatusQuery = { __typename?: 'Query', getSystemHealthStatus: { __typename?: 'SystemHealth', services: Array<{ __typename?: 'SystemHealthService', id: HealthIndicatorId, label: string, status: AdminPanelHealthServiceStatus }> } }; export type GetSystemHealthStatusQuery = { __typename?: 'Query', getSystemHealthStatus: { __typename?: 'SystemHealth', services: Array<{ __typename?: 'SystemHealthService', id: HealthIndicatorId, label: string, status: AdminPanelHealthServiceStatus }> } };
export type ApiKeyFragmentFragment = { __typename?: 'ApiKey', id: any, name: string, expiresAt: string, revokedAt?: string | null };
export type WebhookFragmentFragment = { __typename?: 'Webhook', id: any, targetUrl: string, operations: Array<string>, description?: string | null, secret: string };
export type CreateApiKeyMutationVariables = Exact<{
input: CreateApiKeyDto;
}>;
export type CreateApiKeyMutation = { __typename?: 'Mutation', createApiKey: { __typename?: 'ApiKey', id: any, name: string, expiresAt: string, revokedAt?: string | null } };
export type CreateWebhookMutationVariables = Exact<{
input: CreateWebhookDto;
}>;
export type CreateWebhookMutation = { __typename?: 'Mutation', createWebhook: { __typename?: 'Webhook', id: any, targetUrl: string, operations: Array<string>, description?: string | null, secret: string } };
export type DeleteWebhookMutationVariables = Exact<{
input: DeleteWebhookDto;
}>;
export type DeleteWebhookMutation = { __typename?: 'Mutation', deleteWebhook: boolean };
export type RevokeApiKeyMutationVariables = Exact<{
input: RevokeApiKeyDto;
}>;
export type RevokeApiKeyMutation = { __typename?: 'Mutation', revokeApiKey?: { __typename?: 'ApiKey', id: any } | null };
export type UpdateApiKeyMutationVariables = Exact<{
input: UpdateApiKeyDto;
}>;
export type UpdateApiKeyMutation = { __typename?: 'Mutation', updateApiKey?: { __typename?: 'ApiKey', id: any, name: string, expiresAt: string, revokedAt?: string | null } | null };
export type UpdateWebhookMutationVariables = Exact<{
input: UpdateWebhookDto;
}>;
export type UpdateWebhookMutation = { __typename?: 'Mutation', updateWebhook?: { __typename?: 'Webhook', id: any, targetUrl: string, operations: Array<string>, description?: string | null, secret: string } | null };
export type GetApiKeyQueryVariables = Exact<{
input: GetApiKeyDto;
}>;
export type GetApiKeyQuery = { __typename?: 'Query', apiKey?: { __typename?: 'ApiKey', createdAt: string, id: any, name: string, expiresAt: string, revokedAt?: string | null } | null };
export type GetApiKeysQueryVariables = Exact<{ [key: string]: never; }>;
export type GetApiKeysQuery = { __typename?: 'Query', apiKeys: Array<{ __typename?: 'ApiKey', id: any, name: string, expiresAt: string, revokedAt?: string | null }> };
export type GetWebhookQueryVariables = Exact<{
input: GetWebhookDto;
}>;
export type GetWebhookQuery = { __typename?: 'Query', webhook?: { __typename?: 'Webhook', id: any, targetUrl: string, operations: Array<string>, description?: string | null, secret: string } | null };
export type GetWebhooksQueryVariables = Exact<{ [key: string]: never; }>;
export type GetWebhooksQuery = { __typename?: 'Query', webhooks: Array<{ __typename?: 'Webhook', id: any, targetUrl: string, operations: Array<string>, description?: string | null, secret: string }> };
export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{ export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
input: UpdateLabPublicFeatureFlagInput; input: UpdateLabPublicFeatureFlagInput;
}>; }>;
@ -3647,6 +3839,23 @@ export const RemoteTableFieldsFragmentDoc = gql`
schemaPendingUpdates schemaPendingUpdates
} }
`; `;
export const ApiKeyFragmentFragmentDoc = gql`
fragment ApiKeyFragment on ApiKey {
id
name
expiresAt
revokedAt
}
`;
export const WebhookFragmentFragmentDoc = gql`
fragment WebhookFragment on Webhook {
id
targetUrl
operations
description
secret
}
`;
export const SettingPermissionFragmentFragmentDoc = gql` export const SettingPermissionFragmentFragmentDoc = gql`
fragment SettingPermissionFragment on SettingPermission { fragment SettingPermissionFragment on SettingPermission {
id id
@ -6286,6 +6495,341 @@ export function useGetSystemHealthStatusLazyQuery(baseOptions?: Apollo.LazyQuery
export type GetSystemHealthStatusQueryHookResult = ReturnType<typeof useGetSystemHealthStatusQuery>; export type GetSystemHealthStatusQueryHookResult = ReturnType<typeof useGetSystemHealthStatusQuery>;
export type GetSystemHealthStatusLazyQueryHookResult = ReturnType<typeof useGetSystemHealthStatusLazyQuery>; export type GetSystemHealthStatusLazyQueryHookResult = ReturnType<typeof useGetSystemHealthStatusLazyQuery>;
export type GetSystemHealthStatusQueryResult = Apollo.QueryResult<GetSystemHealthStatusQuery, GetSystemHealthStatusQueryVariables>; export type GetSystemHealthStatusQueryResult = Apollo.QueryResult<GetSystemHealthStatusQuery, GetSystemHealthStatusQueryVariables>;
export const CreateApiKeyDocument = gql`
mutation CreateApiKey($input: CreateApiKeyDTO!) {
createApiKey(input: $input) {
...ApiKeyFragment
}
}
${ApiKeyFragmentFragmentDoc}`;
export type CreateApiKeyMutationFn = Apollo.MutationFunction<CreateApiKeyMutation, CreateApiKeyMutationVariables>;
/**
* __useCreateApiKeyMutation__
*
* To run a mutation, you first call `useCreateApiKeyMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateApiKeyMutation` 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 [createApiKeyMutation, { data, loading, error }] = useCreateApiKeyMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useCreateApiKeyMutation(baseOptions?: Apollo.MutationHookOptions<CreateApiKeyMutation, CreateApiKeyMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateApiKeyMutation, CreateApiKeyMutationVariables>(CreateApiKeyDocument, options);
}
export type CreateApiKeyMutationHookResult = ReturnType<typeof useCreateApiKeyMutation>;
export type CreateApiKeyMutationResult = Apollo.MutationResult<CreateApiKeyMutation>;
export type CreateApiKeyMutationOptions = Apollo.BaseMutationOptions<CreateApiKeyMutation, CreateApiKeyMutationVariables>;
export const CreateWebhookDocument = gql`
mutation CreateWebhook($input: CreateWebhookDTO!) {
createWebhook(input: $input) {
...WebhookFragment
}
}
${WebhookFragmentFragmentDoc}`;
export type CreateWebhookMutationFn = Apollo.MutationFunction<CreateWebhookMutation, CreateWebhookMutationVariables>;
/**
* __useCreateWebhookMutation__
*
* To run a mutation, you first call `useCreateWebhookMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateWebhookMutation` 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 [createWebhookMutation, { data, loading, error }] = useCreateWebhookMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useCreateWebhookMutation(baseOptions?: Apollo.MutationHookOptions<CreateWebhookMutation, CreateWebhookMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateWebhookMutation, CreateWebhookMutationVariables>(CreateWebhookDocument, options);
}
export type CreateWebhookMutationHookResult = ReturnType<typeof useCreateWebhookMutation>;
export type CreateWebhookMutationResult = Apollo.MutationResult<CreateWebhookMutation>;
export type CreateWebhookMutationOptions = Apollo.BaseMutationOptions<CreateWebhookMutation, CreateWebhookMutationVariables>;
export const DeleteWebhookDocument = gql`
mutation DeleteWebhook($input: DeleteWebhookDTO!) {
deleteWebhook(input: $input)
}
`;
export type DeleteWebhookMutationFn = Apollo.MutationFunction<DeleteWebhookMutation, DeleteWebhookMutationVariables>;
/**
* __useDeleteWebhookMutation__
*
* To run a mutation, you first call `useDeleteWebhookMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteWebhookMutation` 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 [deleteWebhookMutation, { data, loading, error }] = useDeleteWebhookMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useDeleteWebhookMutation(baseOptions?: Apollo.MutationHookOptions<DeleteWebhookMutation, DeleteWebhookMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteWebhookMutation, DeleteWebhookMutationVariables>(DeleteWebhookDocument, options);
}
export type DeleteWebhookMutationHookResult = ReturnType<typeof useDeleteWebhookMutation>;
export type DeleteWebhookMutationResult = Apollo.MutationResult<DeleteWebhookMutation>;
export type DeleteWebhookMutationOptions = Apollo.BaseMutationOptions<DeleteWebhookMutation, DeleteWebhookMutationVariables>;
export const RevokeApiKeyDocument = gql`
mutation RevokeApiKey($input: RevokeApiKeyDTO!) {
revokeApiKey(input: $input) {
id
}
}
`;
export type RevokeApiKeyMutationFn = Apollo.MutationFunction<RevokeApiKeyMutation, RevokeApiKeyMutationVariables>;
/**
* __useRevokeApiKeyMutation__
*
* To run a mutation, you first call `useRevokeApiKeyMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useRevokeApiKeyMutation` 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 [revokeApiKeyMutation, { data, loading, error }] = useRevokeApiKeyMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useRevokeApiKeyMutation(baseOptions?: Apollo.MutationHookOptions<RevokeApiKeyMutation, RevokeApiKeyMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<RevokeApiKeyMutation, RevokeApiKeyMutationVariables>(RevokeApiKeyDocument, options);
}
export type RevokeApiKeyMutationHookResult = ReturnType<typeof useRevokeApiKeyMutation>;
export type RevokeApiKeyMutationResult = Apollo.MutationResult<RevokeApiKeyMutation>;
export type RevokeApiKeyMutationOptions = Apollo.BaseMutationOptions<RevokeApiKeyMutation, RevokeApiKeyMutationVariables>;
export const UpdateApiKeyDocument = gql`
mutation UpdateApiKey($input: UpdateApiKeyDTO!) {
updateApiKey(input: $input) {
...ApiKeyFragment
}
}
${ApiKeyFragmentFragmentDoc}`;
export type UpdateApiKeyMutationFn = Apollo.MutationFunction<UpdateApiKeyMutation, UpdateApiKeyMutationVariables>;
/**
* __useUpdateApiKeyMutation__
*
* To run a mutation, you first call `useUpdateApiKeyMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateApiKeyMutation` 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 [updateApiKeyMutation, { data, loading, error }] = useUpdateApiKeyMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useUpdateApiKeyMutation(baseOptions?: Apollo.MutationHookOptions<UpdateApiKeyMutation, UpdateApiKeyMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateApiKeyMutation, UpdateApiKeyMutationVariables>(UpdateApiKeyDocument, options);
}
export type UpdateApiKeyMutationHookResult = ReturnType<typeof useUpdateApiKeyMutation>;
export type UpdateApiKeyMutationResult = Apollo.MutationResult<UpdateApiKeyMutation>;
export type UpdateApiKeyMutationOptions = Apollo.BaseMutationOptions<UpdateApiKeyMutation, UpdateApiKeyMutationVariables>;
export const UpdateWebhookDocument = gql`
mutation UpdateWebhook($input: UpdateWebhookDTO!) {
updateWebhook(input: $input) {
...WebhookFragment
}
}
${WebhookFragmentFragmentDoc}`;
export type UpdateWebhookMutationFn = Apollo.MutationFunction<UpdateWebhookMutation, UpdateWebhookMutationVariables>;
/**
* __useUpdateWebhookMutation__
*
* To run a mutation, you first call `useUpdateWebhookMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateWebhookMutation` 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 [updateWebhookMutation, { data, loading, error }] = useUpdateWebhookMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useUpdateWebhookMutation(baseOptions?: Apollo.MutationHookOptions<UpdateWebhookMutation, UpdateWebhookMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateWebhookMutation, UpdateWebhookMutationVariables>(UpdateWebhookDocument, options);
}
export type UpdateWebhookMutationHookResult = ReturnType<typeof useUpdateWebhookMutation>;
export type UpdateWebhookMutationResult = Apollo.MutationResult<UpdateWebhookMutation>;
export type UpdateWebhookMutationOptions = Apollo.BaseMutationOptions<UpdateWebhookMutation, UpdateWebhookMutationVariables>;
export const GetApiKeyDocument = gql`
query GetApiKey($input: GetApiKeyDTO!) {
apiKey(input: $input) {
...ApiKeyFragment
createdAt
}
}
${ApiKeyFragmentFragmentDoc}`;
/**
* __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: {
* input: // value for 'input'
* },
* });
*/
export function useGetApiKeyQuery(baseOptions: Apollo.QueryHookOptions<GetApiKeyQuery, GetApiKeyQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetApiKeyQuery, GetApiKeyQueryVariables>(GetApiKeyDocument, options);
}
export function useGetApiKeyLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetApiKeyQuery, GetApiKeyQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetApiKeyQuery, GetApiKeyQueryVariables>(GetApiKeyDocument, options);
}
export type GetApiKeyQueryHookResult = ReturnType<typeof useGetApiKeyQuery>;
export type GetApiKeyLazyQueryHookResult = ReturnType<typeof useGetApiKeyLazyQuery>;
export type GetApiKeyQueryResult = Apollo.QueryResult<GetApiKeyQuery, GetApiKeyQueryVariables>;
export const GetApiKeysDocument = gql`
query GetApiKeys {
apiKeys {
...ApiKeyFragment
}
}
${ApiKeyFragmentFragmentDoc}`;
/**
* __useGetApiKeysQuery__
*
* To run a query within a React component, call `useGetApiKeysQuery` and pass it any options that fit your needs.
* When your component renders, `useGetApiKeysQuery` 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 } = useGetApiKeysQuery({
* variables: {
* },
* });
*/
export function useGetApiKeysQuery(baseOptions?: Apollo.QueryHookOptions<GetApiKeysQuery, GetApiKeysQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetApiKeysQuery, GetApiKeysQueryVariables>(GetApiKeysDocument, options);
}
export function useGetApiKeysLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetApiKeysQuery, GetApiKeysQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetApiKeysQuery, GetApiKeysQueryVariables>(GetApiKeysDocument, options);
}
export type GetApiKeysQueryHookResult = ReturnType<typeof useGetApiKeysQuery>;
export type GetApiKeysLazyQueryHookResult = ReturnType<typeof useGetApiKeysLazyQuery>;
export type GetApiKeysQueryResult = Apollo.QueryResult<GetApiKeysQuery, GetApiKeysQueryVariables>;
export const GetWebhookDocument = gql`
query GetWebhook($input: GetWebhookDTO!) {
webhook(input: $input) {
...WebhookFragment
}
}
${WebhookFragmentFragmentDoc}`;
/**
* __useGetWebhookQuery__
*
* To run a query within a React component, call `useGetWebhookQuery` and pass it any options that fit your needs.
* When your component renders, `useGetWebhookQuery` 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 } = useGetWebhookQuery({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useGetWebhookQuery(baseOptions: Apollo.QueryHookOptions<GetWebhookQuery, GetWebhookQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetWebhookQuery, GetWebhookQueryVariables>(GetWebhookDocument, options);
}
export function useGetWebhookLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetWebhookQuery, GetWebhookQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetWebhookQuery, GetWebhookQueryVariables>(GetWebhookDocument, options);
}
export type GetWebhookQueryHookResult = ReturnType<typeof useGetWebhookQuery>;
export type GetWebhookLazyQueryHookResult = ReturnType<typeof useGetWebhookLazyQuery>;
export type GetWebhookQueryResult = Apollo.QueryResult<GetWebhookQuery, GetWebhookQueryVariables>;
export const GetWebhooksDocument = gql`
query GetWebhooks {
webhooks {
...WebhookFragment
}
}
${WebhookFragmentFragmentDoc}`;
/**
* __useGetWebhooksQuery__
*
* To run a query within a React component, call `useGetWebhooksQuery` and pass it any options that fit your needs.
* When your component renders, `useGetWebhooksQuery` 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 } = useGetWebhooksQuery({
* variables: {
* },
* });
*/
export function useGetWebhooksQuery(baseOptions?: Apollo.QueryHookOptions<GetWebhooksQuery, GetWebhooksQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetWebhooksQuery, GetWebhooksQueryVariables>(GetWebhooksDocument, options);
}
export function useGetWebhooksLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetWebhooksQuery, GetWebhooksQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetWebhooksQuery, GetWebhooksQueryVariables>(GetWebhooksDocument, options);
}
export type GetWebhooksQueryHookResult = ReturnType<typeof useGetWebhooksQuery>;
export type GetWebhooksLazyQueryHookResult = ReturnType<typeof useGetWebhooksLazyQuery>;
export type GetWebhooksQueryResult = Apollo.QueryResult<GetWebhooksQuery, GetWebhooksQueryVariables>;
export const UpdateLabPublicFeatureFlagDocument = gql` export const UpdateLabPublicFeatureFlagDocument = gql`
mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) { mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) {
updateLabPublicFeatureFlag(input: $input) { updateLabPublicFeatureFlag(input: $input) {

View File

@ -87,6 +87,18 @@ export type ApiConfig = {
mutationMaximumAffectedRecords: Scalars['Float']; mutationMaximumAffectedRecords: Scalars['Float'];
}; };
export type ApiKey = {
__typename?: 'ApiKey';
createdAt: Scalars['DateTime'];
expiresAt: Scalars['DateTime'];
id: Scalars['UUID'];
name: Scalars['String'];
revokedAt?: Maybe<Scalars['DateTime']>;
updatedAt: Scalars['DateTime'];
workspace: Workspace;
workspaceId: Scalars['String'];
};
export type ApiKeyToken = { export type ApiKeyToken = {
__typename?: 'ApiKeyToken'; __typename?: 'ApiKeyToken';
token: Scalars['String']; token: Scalars['String'];
@ -435,6 +447,12 @@ export type ConnectionParametersOutput = {
username: Scalars['String']; username: Scalars['String'];
}; };
export type CreateApiKeyDto = {
expiresAt: Scalars['String'];
name: Scalars['String'];
revokedAt?: InputMaybe<Scalars['String']>;
};
export type CreateApprovedAccessDomainInput = { export type CreateApprovedAccessDomainInput = {
domain: Scalars['String']; domain: Scalars['String'];
email: Scalars['String']; email: Scalars['String'];
@ -491,6 +509,13 @@ export type CreateServerlessFunctionInput = {
timeoutSeconds?: InputMaybe<Scalars['Float']>; timeoutSeconds?: InputMaybe<Scalars['Float']>;
}; };
export type CreateWebhookDto = {
description?: InputMaybe<Scalars['String']>;
operations: Array<Scalars['String']>;
secret?: InputMaybe<Scalars['String']>;
targetUrl: Scalars['String'];
};
export type CreateWorkflowVersionStepInput = { export type CreateWorkflowVersionStepInput = {
/** Next step ID */ /** Next step ID */
nextStepId?: InputMaybe<Scalars['String']>; nextStepId?: InputMaybe<Scalars['String']>;
@ -572,6 +597,10 @@ export type DeleteSsoOutput = {
identityProviderId: Scalars['String']; identityProviderId: Scalars['String'];
}; };
export type DeleteWebhookDto = {
id: Scalars['String'];
};
export type DeleteWorkflowVersionStepInput = { export type DeleteWorkflowVersionStepInput = {
/** Step to delete ID */ /** Step to delete ID */
stepId: Scalars['String']; stepId: Scalars['String'];
@ -647,9 +676,11 @@ export enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED', IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED' IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED'
} }
export type Field = { export type Field = {
@ -776,6 +807,10 @@ export type FullName = {
lastName: Scalars['String']; lastName: Scalars['String'];
}; };
export type GetApiKeyDto = {
id: Scalars['String'];
};
export type GetAuthorizationUrlForSsoInput = { export type GetAuthorizationUrlForSsoInput = {
identityProviderId: Scalars['String']; identityProviderId: Scalars['String'];
workspaceInviteHash?: InputMaybe<Scalars['String']>; workspaceInviteHash?: InputMaybe<Scalars['String']>;
@ -801,6 +836,10 @@ export type GetServerlessFunctionSourceCodeInput = {
version?: Scalars['String']; version?: Scalars['String'];
}; };
export type GetWebhookDto = {
id: Scalars['String'];
};
export enum HealthIndicatorId { export enum HealthIndicatorId {
app = 'app', app = 'app',
connectedAccount = 'connectedAccount', connectedAccount = 'connectedAccount',
@ -972,6 +1011,7 @@ export type Mutation = {
checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>; checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>;
checkoutSession: BillingSessionOutput; checkoutSession: BillingSessionOutput;
computeStepOutputSchema: Scalars['JSON']; computeStepOutputSchema: Scalars['JSON'];
createApiKey: ApiKey;
createApprovedAccessDomain: ApprovedAccessDomain; createApprovedAccessDomain: ApprovedAccessDomain;
createDatabaseConfigVariable: Scalars['Boolean']; createDatabaseConfigVariable: Scalars['Boolean'];
createDraftFromWorkflowVersion: WorkflowVersion; createDraftFromWorkflowVersion: WorkflowVersion;
@ -983,6 +1023,7 @@ export type Mutation = {
createOneRole: Role; createOneRole: Role;
createOneServerlessFunction: ServerlessFunction; createOneServerlessFunction: ServerlessFunction;
createSAMLIdentityProvider: SetupSsoOutput; createSAMLIdentityProvider: SetupSsoOutput;
createWebhook: Webhook;
createWorkflowVersionStep: WorkflowAction; createWorkflowVersionStep: WorkflowAction;
deactivateWorkflowVersion: Scalars['Boolean']; deactivateWorkflowVersion: Scalars['Boolean'];
deleteApprovedAccessDomain: Scalars['Boolean']; deleteApprovedAccessDomain: Scalars['Boolean'];
@ -994,6 +1035,7 @@ export type Mutation = {
deleteOneServerlessFunction: ServerlessFunction; deleteOneServerlessFunction: ServerlessFunction;
deleteSSOIdentityProvider: DeleteSsoOutput; deleteSSOIdentityProvider: DeleteSsoOutput;
deleteUser: User; deleteUser: User;
deleteWebhook: Scalars['Boolean'];
deleteWorkflowVersionStep: WorkflowAction; deleteWorkflowVersionStep: WorkflowAction;
deleteWorkspaceInvitation: Scalars['String']; deleteWorkspaceInvitation: Scalars['String'];
disablePostgresProxy: PostgresCredentials; disablePostgresProxy: PostgresCredentials;
@ -1014,6 +1056,7 @@ export type Mutation = {
renewToken: AuthTokens; renewToken: AuthTokens;
resendEmailVerificationToken: ResendEmailVerificationTokenOutput; resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
resendWorkspaceInvitation: SendInvitationsOutput; resendWorkspaceInvitation: SendInvitationsOutput;
revokeApiKey?: Maybe<ApiKey>;
runWorkflowVersion: WorkflowRun; runWorkflowVersion: WorkflowRun;
saveImapSmtpCaldav: ImapSmtpCaldavConnectionSuccess; saveImapSmtpCaldav: ImapSmtpCaldavConnectionSuccess;
sendInvitations: SendInvitationsOutput; sendInvitations: SendInvitationsOutput;
@ -1027,6 +1070,7 @@ export type Mutation = {
switchToEnterprisePlan: BillingUpdateOutput; switchToEnterprisePlan: BillingUpdateOutput;
switchToYearlyInterval: BillingUpdateOutput; switchToYearlyInterval: BillingUpdateOutput;
trackAnalytics: Analytics; trackAnalytics: Analytics;
updateApiKey?: Maybe<ApiKey>;
updateDatabaseConfigVariable: Scalars['Boolean']; updateDatabaseConfigVariable: Scalars['Boolean'];
updateLabPublicFeatureFlag: FeatureFlagDto; updateLabPublicFeatureFlag: FeatureFlagDto;
updateOneAgent: Agent; updateOneAgent: Agent;
@ -1035,6 +1079,7 @@ export type Mutation = {
updateOneRole: Role; updateOneRole: Role;
updateOneServerlessFunction: ServerlessFunction; updateOneServerlessFunction: ServerlessFunction;
updatePasswordViaResetToken: InvalidatePassword; updatePasswordViaResetToken: InvalidatePassword;
updateWebhook?: Maybe<Webhook>;
updateWorkflowRunStep: WorkflowAction; updateWorkflowRunStep: WorkflowAction;
updateWorkflowVersionStep: WorkflowAction; updateWorkflowVersionStep: WorkflowAction;
updateWorkspace: Workspace; updateWorkspace: Workspace;
@ -1088,6 +1133,11 @@ export type MutationComputeStepOutputSchemaArgs = {
}; };
export type MutationCreateApiKeyArgs = {
input: CreateApiKeyDto;
};
export type MutationCreateApprovedAccessDomainArgs = { export type MutationCreateApprovedAccessDomainArgs = {
input: CreateApprovedAccessDomainInput; input: CreateApprovedAccessDomainInput;
}; };
@ -1137,6 +1187,11 @@ export type MutationCreateSamlIdentityProviderArgs = {
}; };
export type MutationCreateWebhookArgs = {
input: CreateWebhookDto;
};
export type MutationCreateWorkflowVersionStepArgs = { export type MutationCreateWorkflowVersionStepArgs = {
input: CreateWorkflowVersionStepInput; input: CreateWorkflowVersionStepInput;
}; };
@ -1182,6 +1237,11 @@ export type MutationDeleteSsoIdentityProviderArgs = {
}; };
export type MutationDeleteWebhookArgs = {
input: DeleteWebhookDto;
};
export type MutationDeleteWorkflowVersionStepArgs = { export type MutationDeleteWorkflowVersionStepArgs = {
input: DeleteWorkflowVersionStepInput; input: DeleteWorkflowVersionStepInput;
}; };
@ -1273,6 +1333,11 @@ export type MutationResendWorkspaceInvitationArgs = {
}; };
export type MutationRevokeApiKeyArgs = {
input: RevokeApiKeyDto;
};
export type MutationRunWorkflowVersionArgs = { export type MutationRunWorkflowVersionArgs = {
input: RunWorkflowVersionInput; input: RunWorkflowVersionInput;
}; };
@ -1331,6 +1396,11 @@ export type MutationTrackAnalyticsArgs = {
}; };
export type MutationUpdateApiKeyArgs = {
input: UpdateApiKeyDto;
};
export type MutationUpdateDatabaseConfigVariableArgs = { export type MutationUpdateDatabaseConfigVariableArgs = {
key: Scalars['String']; key: Scalars['String'];
value: Scalars['JSON']; value: Scalars['JSON'];
@ -1373,6 +1443,11 @@ export type MutationUpdatePasswordViaResetTokenArgs = {
}; };
export type MutationUpdateWebhookArgs = {
input: UpdateWebhookDto;
};
export type MutationUpdateWorkflowRunStepArgs = { export type MutationUpdateWorkflowRunStepArgs = {
input: UpdateWorkflowRunStepInput; input: UpdateWorkflowRunStepInput;
}; };
@ -1655,6 +1730,8 @@ export type PublishServerlessFunctionInput = {
export type Query = { export type Query = {
__typename?: 'Query'; __typename?: 'Query';
apiKey?: Maybe<ApiKey>;
apiKeys: Array<ApiKey>;
billingPortalSession: BillingSessionOutput; billingPortalSession: BillingSessionOutput;
checkUserExists: CheckUserExistOutput; checkUserExists: CheckUserExistOutput;
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
@ -1693,6 +1770,13 @@ export type Query = {
search: SearchResultConnection; search: SearchResultConnection;
validatePasswordResetToken: ValidatePasswordResetToken; validatePasswordResetToken: ValidatePasswordResetToken;
versionInfo: VersionInfo; versionInfo: VersionInfo;
webhook?: Maybe<Webhook>;
webhooks: Array<Webhook>;
};
export type QueryApiKeyArgs = {
input: GetApiKeyDto;
}; };
@ -1805,6 +1889,11 @@ export type QueryValidatePasswordResetTokenArgs = {
passwordResetToken: Scalars['String']; passwordResetToken: Scalars['String'];
}; };
export type QueryWebhookArgs = {
input: GetWebhookDto;
};
export type QueueMetricsData = { export type QueueMetricsData = {
__typename?: 'QueueMetricsData'; __typename?: 'QueueMetricsData';
data: Array<QueueMetricsSeries>; data: Array<QueueMetricsSeries>;
@ -1882,6 +1971,10 @@ export type ResendEmailVerificationTokenOutput = {
success: Scalars['Boolean']; success: Scalars['Boolean'];
}; };
export type RevokeApiKeyDto = {
id: Scalars['String'];
};
export type Role = { export type Role = {
__typename?: 'Role'; __typename?: 'Role';
canDestroyAllObjectRecords: Scalars['Boolean']; canDestroyAllObjectRecords: Scalars['Boolean'];
@ -2244,6 +2337,13 @@ export type UpdateAgentInput = {
responseFormat?: InputMaybe<Scalars['JSON']>; responseFormat?: InputMaybe<Scalars['JSON']>;
}; };
export type UpdateApiKeyDto = {
expiresAt?: InputMaybe<Scalars['String']>;
id: Scalars['String'];
name?: InputMaybe<Scalars['String']>;
revokedAt?: InputMaybe<Scalars['String']>;
};
export type UpdateFieldInput = { export type UpdateFieldInput = {
defaultValue?: InputMaybe<Scalars['JSON']>; defaultValue?: InputMaybe<Scalars['JSON']>;
description?: InputMaybe<Scalars['String']>; description?: InputMaybe<Scalars['String']>;
@ -2317,6 +2417,14 @@ export type UpdateServerlessFunctionInput = {
timeoutSeconds?: InputMaybe<Scalars['Float']>; timeoutSeconds?: InputMaybe<Scalars['Float']>;
}; };
export type UpdateWebhookDto = {
description?: InputMaybe<Scalars['String']>;
id: Scalars['String'];
operations?: InputMaybe<Array<Scalars['String']>>;
secret?: InputMaybe<Scalars['String']>;
targetUrl?: InputMaybe<Scalars['String']>;
};
export type UpdateWorkflowRunStepInput = { export type UpdateWorkflowRunStepInput = {
/** Step to update in JSON format */ /** Step to update in JSON format */
step: Scalars['JSON']; step: Scalars['JSON'];
@ -2449,6 +2557,20 @@ export type VersionInfo = {
latestVersion: Scalars['String']; latestVersion: Scalars['String'];
}; };
export type Webhook = {
__typename?: 'Webhook';
createdAt: Scalars['DateTime'];
deletedAt?: Maybe<Scalars['DateTime']>;
description?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
operations: Array<Scalars['String']>;
secret: Scalars['String'];
targetUrl: Scalars['String'];
updatedAt: Scalars['DateTime'];
workspace: Workspace;
workspaceId: Scalars['String'];
};
export type WorkerQueueMetrics = { export type WorkerQueueMetrics = {
__typename?: 'WorkerQueueMetrics'; __typename?: 'WorkerQueueMetrics';
active: Scalars['Float']; active: Scalars['Float'];

View File

@ -2,11 +2,9 @@ import styled from '@emotion/styled';
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { useUpdateApiKeyMutation } from '~/generated-metadata/graphql';
const StyledComboInputContainer = styled.div` const StyledComboInputContainer = styled.div`
display: flex; display: flex;
@ -29,9 +27,7 @@ export const ApiKeyNameInput = ({
disabled, disabled,
onNameUpdate, onNameUpdate,
}: ApiKeyNameInputProps) => { }: ApiKeyNameInputProps) => {
const { updateOneRecord: updateApiKey } = useUpdateOneRecord<ApiKey>({ const [updateApiKey] = useUpdateApiKeyMutation();
objectNameSingular: CoreObjectNameSingular.ApiKey,
});
// TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com) // TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -43,10 +39,18 @@ export const ApiKeyNameInput = ({
if (!apiKeyName) { if (!apiKeyName) {
return; return;
} }
await updateApiKey({ const { data: updatedApiKeyData } = await updateApiKey({
idToUpdate: apiKeyId, variables: {
updateOneRecordInput: { name }, input: {
id: apiKeyId,
name,
},
},
}); });
const updatedApiKey = updatedApiKeyData?.updateApiKey;
if (isDefined(updatedApiKey)) {
onNameUpdate?.(updatedApiKey.name);
}
}, 500), }, 500),
[updateApiKey, onNameUpdate], [updateApiKey, onNameUpdate],
); );

View File

@ -1,7 +1,7 @@
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { ApiFieldItem } from '@/settings/developers/types/api-key/ApiFieldItem'; import { formatExpiration } from '@/settings/developers/utils/formatExpiration';
import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow'; import { TableRow } from '@/ui/layout/table/components/TableRow';
import { import {
@ -9,6 +9,7 @@ import {
OverflowingTextWithTooltip, OverflowingTextWithTooltip,
} from 'twenty-ui/display'; } from 'twenty-ui/display';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
import { ApiKey } from '~/generated-metadata/graphql';
export const StyledApisFieldTableRow = styled(TableRow)` export const StyledApisFieldTableRow = styled(TableRow)`
grid-template-columns: 312px auto 28px; grid-template-columns: 312px auto 28px;
@ -34,27 +35,28 @@ const StyledIconChevronRight = styled(IconChevronRight)`
`; `;
export const SettingsApiKeysFieldItemTableRow = ({ export const SettingsApiKeysFieldItemTableRow = ({
fieldItem, apiKey,
to, to,
}: { }: {
fieldItem: ApiFieldItem; apiKey: Pick<ApiKey, 'id' | 'name' | 'expiresAt' | 'revokedAt'>;
to: string; to: string;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const formattedExpiration = formatExpiration(apiKey.expiresAt || null);
return ( return (
<StyledApisFieldTableRow to={to}> <StyledApisFieldTableRow to={to}>
<StyledNameTableCell> <StyledNameTableCell>
<OverflowingTextWithTooltip text={fieldItem.name} /> <OverflowingTextWithTooltip text={apiKey.name} />
</StyledNameTableCell> </StyledNameTableCell>
<TableCell <TableCell
color={ color={
fieldItem.expiration === 'Expired' formattedExpiration === 'Expired'
? theme.font.color.danger ? theme.font.color.danger
: theme.font.color.tertiary : theme.font.color.tertiary
} }
> >
{fieldItem.expiration} {formattedExpiration}
</TableCell> </TableCell>
<StyledIconTableCell> <StyledIconTableCell>
<StyledIconChevronRight <StyledIconChevronRight

View File

@ -1,9 +1,4 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow'; import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow';
import { ApiFieldItem } from '@/settings/developers/types/api-key/ApiFieldItem';
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
import { formatExpirations } from '@/settings/developers/utils/formatExpiration';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { Table } from '@/ui/layout/table/components/Table'; import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody'; import { TableBody } from '@/ui/layout/table/components/TableBody';
@ -12,6 +7,7 @@ import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
import { useGetApiKeysQuery } from '~/generated-metadata/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledTableBody = styled(TableBody)` const StyledTableBody = styled(TableBody)`
@ -33,10 +29,9 @@ const StyledTableRow = styled(TableRow)`
`; `;
export const SettingsApiKeysTable = () => { export const SettingsApiKeysTable = () => {
const { records: apiKeys } = useFindManyRecords<ApiKey>({ const { data: apiKeysData } = useGetApiKeysQuery();
objectNameSingular: CoreObjectNameSingular.ApiKey,
filter: { revokedAt: { is: 'NULL' } }, const apiKeys = apiKeysData?.apiKeys;
});
return ( return (
<Table> <Table>
@ -49,14 +44,14 @@ export const SettingsApiKeysTable = () => {
</TableHeader> </TableHeader>
<TableHeader></TableHeader> <TableHeader></TableHeader>
</StyledTableRow> </StyledTableRow>
{!!apiKeys.length && ( {!!apiKeys?.length && (
<StyledTableBody> <StyledTableBody>
{formatExpirations(apiKeys).map((fieldItem) => ( {apiKeys.map((apiKey) => (
<SettingsApiKeysFieldItemTableRow <SettingsApiKeysFieldItemTableRow
key={fieldItem.id} key={apiKey.id}
fieldItem={fieldItem as ApiFieldItem} apiKey={apiKey}
to={getSettingsPath(SettingsPath.ApiKeyDetail, { to={getSettingsPath(SettingsPath.ApiKeyDetail, {
apiKeyId: fieldItem.id, apiKeyId: apiKey.id,
})} })}
/> />
))} ))}

View File

@ -79,7 +79,7 @@ export const SettingsDevelopersWebhookForm = ({
handleSave, handleSave,
updateOperation, updateOperation,
removeOperation, removeOperation,
deleteWebhook, handleDelete,
isCreationMode, isCreationMode,
error, error,
} = useWebhookForm({ webhookId, mode }); } = useWebhookForm({ webhookId, mode });
@ -285,7 +285,7 @@ export const SettingsDevelopersWebhookForm = ({
Please type "yes" to confirm you want to delete this webhook. Please type "yes" to confirm you want to delete this webhook.
</Trans> </Trans>
} }
onConfirmClick={deleteWebhook} onConfirmClick={handleDelete}
confirmButtonText={t`Delete`} confirmButtonText={t`Delete`}
/> />
)} )}

View File

@ -1,11 +1,11 @@
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow'; import { TableRow } from '@/ui/layout/table/components/TableRow';
import { getUrlHostnameOrThrow, isValidUrl } from 'twenty-shared/utils'; import { getUrlHostnameOrThrow, isValidUrl } from 'twenty-shared/utils';
import { IconChevronRight } from 'twenty-ui/display'; import { IconChevronRight } from 'twenty-ui/display';
import { Webhook } from '~/generated-metadata/graphql';
export const StyledApisFieldTableRow = styled(TableRow)` export const StyledApisFieldTableRow = styled(TableRow)`
grid-template-columns: 1fr 28px; grid-template-columns: 1fr 28px;
@ -28,10 +28,13 @@ const StyledIconChevronRight = styled(IconChevronRight)`
`; `;
export const SettingsDevelopersWebhookTableRow = ({ export const SettingsDevelopersWebhookTableRow = ({
fieldItem, webhook,
to, to,
}: { }: {
fieldItem: Webhook; webhook: Pick<
Webhook,
'id' | 'targetUrl' | 'operations' | 'description' | 'secret'
>;
to: string; to: string;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
@ -39,9 +42,9 @@ export const SettingsDevelopersWebhookTableRow = ({
return ( return (
<StyledApisFieldTableRow to={to}> <StyledApisFieldTableRow to={to}>
<StyledUrlTableCell> <StyledUrlTableCell>
{isValidUrl(fieldItem.targetUrl) {isValidUrl(webhook.targetUrl)
? getUrlHostnameOrThrow(fieldItem.targetUrl) ? getUrlHostnameOrThrow(webhook.targetUrl)
: fieldItem.targetUrl} : webhook.targetUrl}
</StyledUrlTableCell> </StyledUrlTableCell>
<StyledIconTableCell> <StyledIconTableCell>
<StyledIconChevronRight <StyledIconChevronRight

View File

@ -1,14 +1,12 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SettingsDevelopersWebhookTableRow } from '@/settings/developers/components/SettingsDevelopersWebhookTableRow'; import { SettingsDevelopersWebhookTableRow } from '@/settings/developers/components/SettingsDevelopersWebhookTableRow';
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { Table } from '@/ui/layout/table/components/Table'; import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody'; import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader'; import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow'; import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useGetWebhooksQuery } from '~/generated-metadata/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledTableBody = styled(TableBody)` const StyledTableBody = styled(TableBody)`
@ -22,9 +20,9 @@ const StyledTableRow = styled(TableRow)`
`; `;
export const SettingsWebhooksTable = () => { export const SettingsWebhooksTable = () => {
const { records: webhooks } = useFindManyRecords<Webhook>({ const { data: webhooksData } = useGetWebhooksQuery();
objectNameSingular: CoreObjectNameSingular.Webhook,
}); const webhooks = webhooksData?.webhooks;
return ( return (
<Table> <Table>
@ -32,12 +30,12 @@ export const SettingsWebhooksTable = () => {
<TableHeader>URL</TableHeader> <TableHeader>URL</TableHeader>
<TableHeader></TableHeader> <TableHeader></TableHeader>
</StyledTableRow> </StyledTableRow>
{!!webhooks.length && ( {!!webhooks?.length && (
<StyledTableBody> <StyledTableBody>
{webhooks.map((webhookFieldItem) => ( {webhooks.map((webhookFieldItem) => (
<SettingsDevelopersWebhookTableRow <SettingsDevelopersWebhookTableRow
key={webhookFieldItem.id} key={webhookFieldItem.id}
fieldItem={webhookFieldItem} webhook={webhookFieldItem}
to={getSettingsPath(SettingsPath.WebhookDetail, { to={getSettingsPath(SettingsPath.WebhookDetail, {
webhookId: webhookFieldItem.id, webhookId: webhookFieldItem.id,
})} })}

View File

@ -1,19 +1,20 @@
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow'; import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow';
import { ComponentDecorator } from 'twenty-ui/testing'; import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof SettingsApiKeysFieldItemTableRow> = { const meta: Meta<typeof SettingsApiKeysFieldItemTableRow> = {
title: 'Modules/Settings/Developers/ApiKeys/SettingsApiKeysFieldItemTableRow', title: 'Modules/Settings/Developers/ApiKeys/SettingsApiKeysFieldItemTableRow',
component: SettingsApiKeysFieldItemTableRow, component: SettingsApiKeysFieldItemTableRow,
decorators: [ComponentDecorator], decorators: [ComponentDecorator, RouterDecorator],
args: { args: {
fieldItem: { apiKey: {
id: '3f4a42e8-b81f-4f8c-9c20-1602e6b34791', id: '3f4a42e8-b81f-4f8c-9c20-1602e6b34791',
name: 'Zapier Api Key', name: 'Zapier Api Key',
type: 'internal', expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days from now
expiration: 'In 3 days', revokedAt: null,
}, },
to: '/settings/developers/api-keys/3f4a42e8-b81f-4f8c-9c20-1602e6b34791',
}, },
}; };

View File

@ -35,7 +35,7 @@ export const CreateMode: Story = {
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
await canvas.findByText('New Webhook', undefined, { timeout: 10000 }); await canvas.findByText('New Webhook', undefined, { timeout: 3000 });
await canvas.findByPlaceholderText('https://example.com/webhook'); await canvas.findByPlaceholderText('https://example.com/webhook');
await canvas.findByPlaceholderText('Write a description'); await canvas.findByPlaceholderText('Write a description');
@ -48,15 +48,21 @@ export const EditMode: Story = {
mode: WebhookFormMode.Edit, mode: WebhookFormMode.Edit,
webhookId: '1234', webhookId: '1234',
}, },
parameters: {
msw: graphqlMocks,
},
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
await canvas.findByDisplayValue('https://example.com/webhook', undefined, { await canvas.findByDisplayValue(
timeout: 10000, 'https://api.slackbot.io/webhooks/twenty',
}); undefined,
await canvas.findByDisplayValue('A Sample Description'); {
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('Danger zone'); await canvas.findByText('Danger zone');
await canvas.findByText('Delete this webhook'); await canvas.findByText('Delete this webhook');

View File

@ -0,0 +1,10 @@
import gql from 'graphql-tag';
export const API_KEY_FRAGMENT = gql`
fragment ApiKeyFragment on ApiKey {
id
name
expiresAt
revokedAt
}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
export const WEBHOOK_FRAGMENT = gql`
fragment WebhookFragment on Webhook {
id
targetUrl
operations
description
secret
}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
import { API_KEY_FRAGMENT } from '../fragments/apiKeyFragment';
export const CREATE_API_KEY = gql`
mutation CreateApiKey($input: CreateApiKeyDTO!) {
createApiKey(input: $input) {
...ApiKeyFragment
}
}
${API_KEY_FRAGMENT}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
import { WEBHOOK_FRAGMENT } from '../fragments/webhookFragment';
export const CREATE_WEBHOOK = gql`
mutation CreateWebhook($input: CreateWebhookDTO!) {
createWebhook(input: $input) {
...WebhookFragment
}
}
${WEBHOOK_FRAGMENT}
`;

View File

@ -0,0 +1,7 @@
import gql from 'graphql-tag';
export const DELETE_WEBHOOK = gql`
mutation DeleteWebhook($input: DeleteWebhookDTO!) {
deleteWebhook(input: $input)
}
`;

View File

@ -0,0 +1,9 @@
import gql from 'graphql-tag';
export const REVOKE_API_KEY = gql`
mutation RevokeApiKey($input: RevokeApiKeyDTO!) {
revokeApiKey(input: $input) {
id
}
}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
import { API_KEY_FRAGMENT } from '../fragments/apiKeyFragment';
export const UPDATE_API_KEY = gql`
mutation UpdateApiKey($input: UpdateApiKeyDTO!) {
updateApiKey(input: $input) {
...ApiKeyFragment
}
}
${API_KEY_FRAGMENT}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
import { WEBHOOK_FRAGMENT } from '../fragments/webhookFragment';
export const UPDATE_WEBHOOK = gql`
mutation UpdateWebhook($input: UpdateWebhookDTO!) {
updateWebhook(input: $input) {
...WebhookFragment
}
}
${WEBHOOK_FRAGMENT}
`;

View File

@ -0,0 +1,12 @@
import gql from 'graphql-tag';
import { API_KEY_FRAGMENT } from '../fragments/apiKeyFragment';
export const GET_API_KEY = gql`
query GetApiKey($input: GetApiKeyDTO!) {
apiKey(input: $input) {
...ApiKeyFragment
createdAt
}
}
${API_KEY_FRAGMENT}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
import { API_KEY_FRAGMENT } from '../fragments/apiKeyFragment';
export const GET_API_KEYS = gql`
query GetApiKeys {
apiKeys {
...ApiKeyFragment
}
}
${API_KEY_FRAGMENT}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
import { WEBHOOK_FRAGMENT } from '../fragments/webhookFragment';
export const GET_WEBHOOK = gql`
query GetWebhook($input: GetWebhookDTO!) {
webhook(input: $input) {
...WebhookFragment
}
}
${WEBHOOK_FRAGMENT}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
import { WEBHOOK_FRAGMENT } from '../fragments/webhookFragment';
export const GET_WEBHOOKS = gql`
query GetWebhooks {
webhooks {
...WebhookFragment
}
}
${WEBHOOK_FRAGMENT}
`;

View File

@ -5,16 +5,15 @@ import { MemoryRouter } from 'react-router-dom';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import { WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode'; import { WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode';
import { ApolloError } from '@apollo/client'; import { CREATE_WEBHOOK } from '@/settings/developers/graphql/mutations/createWebhook';
import { DELETE_WEBHOOK } from '@/settings/developers/graphql/mutations/deleteWebhook';
import { UPDATE_WEBHOOK } from '@/settings/developers/graphql/mutations/updateWebhook';
import { GET_WEBHOOK } from '@/settings/developers/graphql/queries/getWebhook';
import { useWebhookForm } from '../useWebhookForm'; import { useWebhookForm } from '../useWebhookForm';
// Mock dependencies
const mockNavigateSettings = jest.fn(); const mockNavigateSettings = jest.fn();
const mockEnqueueSuccessSnackBar = jest.fn(); const mockEnqueueSuccessSnackBar = jest.fn();
const mockEnqueueErrorSnackBar = jest.fn(); const mockEnqueueErrorSnackBar = jest.fn();
const mockCreateOneRecord = jest.fn();
const mockUpdateOneRecord = jest.fn();
const mockDeleteOneRecord = jest.fn();
jest.mock('~/hooks/useNavigateSettings', () => ({ jest.mock('~/hooks/useNavigateSettings', () => ({
useNavigateSettings: () => mockNavigateSettings, useNavigateSettings: () => mockNavigateSettings,
@ -27,32 +26,108 @@ jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
}), }),
})); }));
jest.mock('@/object-record/hooks/useCreateOneRecord', () => ({ const createMockWebhookData = (overrides = {}) => ({
useCreateOneRecord: () => ({ id: 'test-webhook-id',
createOneRecord: mockCreateOneRecord, targetUrl: 'https://test.com/webhook',
}), operations: ['person.created'],
})); description: 'Test webhook',
secret: 'test-secret',
...overrides,
});
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({ const createSuccessfulCreateMock = (webhookData = {}) => ({
useUpdateOneRecord: () => ({ request: {
updateOneRecord: mockUpdateOneRecord, query: CREATE_WEBHOOK,
}), variables: {
})); input: {
targetUrl: 'https://test.com/webhook',
operations: ['person.created'],
description: 'Test webhook',
secret: 'test-secret',
...webhookData,
},
},
},
result: {
data: {
createWebhook: createMockWebhookData(webhookData),
},
},
});
jest.mock('@/object-record/hooks/useDeleteOneRecord', () => ({ const createSuccessfulUpdateMock = (webhookId: string, webhookData = {}) => ({
useDeleteOneRecord: () => ({ request: {
deleteOneRecord: mockDeleteOneRecord, query: UPDATE_WEBHOOK,
}), variables: {
})); input: {
id: webhookId,
targetUrl: 'https://updated.com/webhook',
operations: ['person.updated'],
description: 'Updated webhook',
secret: 'updated-secret',
...webhookData,
},
},
},
result: {
data: {
updateWebhook: createMockWebhookData({
id: webhookId,
targetUrl: 'https://updated.com/webhook',
operations: ['person.updated'],
description: 'Updated webhook',
secret: 'updated-secret',
...webhookData,
}),
},
},
});
jest.mock('@/object-record/hooks/useFindOneRecord', () => ({ const createSuccessfulDeleteMock = (webhookId: string) => ({
useFindOneRecord: () => ({ request: {
loading: false, query: DELETE_WEBHOOK,
}), variables: {
})); input: {
id: webhookId,
},
},
},
result: {
data: {
deleteWebhook: {
id: webhookId,
},
},
},
});
const Wrapper = ({ children }: { children: ReactNode }) => ( const createGetWebhookMock = (webhookId: string, webhookData = {}) => ({
<MockedProvider addTypename={false}> request: {
query: GET_WEBHOOK,
variables: {
input: {
id: webhookId,
},
},
},
result: {
data: {
webhook: createMockWebhookData({
id: webhookId,
...webhookData,
}),
},
},
});
const Wrapper = ({
children,
mocks = [],
}: {
children: ReactNode;
mocks?: any[];
}) => (
<MockedProvider mocks={mocks} addTypename={false}>
<RecoilRoot> <RecoilRoot>
<MemoryRouter>{children}</MemoryRouter> <MemoryRouter>{children}</MemoryRouter>
</RecoilRoot> </RecoilRoot>
@ -68,7 +143,7 @@ describe('useWebhookForm', () => {
it('should initialize with default values in create mode', () => { it('should initialize with default values in create mode', () => {
const { result } = renderHook( const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }), () => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper }, { wrapper: ({ children }) => <Wrapper>{children}</Wrapper> },
); );
expect(result.current.isCreationMode).toBe(true); expect(result.current.isCreationMode).toBe(true);
@ -81,15 +156,15 @@ describe('useWebhookForm', () => {
}); });
it('should handle webhook creation successfully', async () => { it('should handle webhook creation successfully', async () => {
const mockCreatedWebhook = { const mocks = [createSuccessfulCreateMock()];
id: 'new-webhook-id',
targetUrl: 'https://test.com/webhook',
};
mockCreateOneRecord.mockResolvedValue(mockCreatedWebhook);
const { result } = renderHook( const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }), () => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper }, {
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
); );
const formData = { const formData = {
@ -103,28 +178,36 @@ describe('useWebhookForm', () => {
await result.current.handleSave(formData); await result.current.handleSave(formData);
}); });
expect(mockCreateOneRecord).toHaveBeenCalledWith({
id: expect.any(String),
targetUrl: 'https://test.com/webhook',
description: 'Test webhook',
operations: ['person.created'],
secret: 'test-secret',
});
expect(mockEnqueueSuccessSnackBar).toHaveBeenCalledWith({ expect(mockEnqueueSuccessSnackBar).toHaveBeenCalledWith({
message: 'Webhook https://test.com/webhook created successfully', message: 'Webhook https://test.com/webhook created successfully',
}); });
}); });
it('should handle creation errors', async () => { it('should handle creation errors', async () => {
const error = new ApolloError({ const errorMock = {
graphQLErrors: [{ message: 'Creation failed' }], request: {
}); query: CREATE_WEBHOOK,
mockCreateOneRecord.mockRejectedValue(error); variables: {
input: {
targetUrl: 'https://test.com/webhook',
operations: ['person.created'],
description: 'Test webhook',
secret: 'test-secret',
},
},
},
error: new Error('Creation failed'),
};
const mocks = [errorMock];
const { result } = renderHook( const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }), () => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper }, {
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
); );
const formData = { const formData = {
@ -139,16 +222,24 @@ describe('useWebhookForm', () => {
}); });
expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({ expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({
apolloError: error, apolloError: expect.any(Error),
}); });
}); });
it('should clean and format operations correctly', async () => { it('should clean and format operations correctly', async () => {
mockCreateOneRecord.mockResolvedValue({ id: 'test-id' }); const mocks = [
createSuccessfulCreateMock({
operations: ['person.created', 'company.updated'],
}),
];
const { result } = renderHook( const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }), () => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper }, {
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
); );
const formData = { const formData = {
@ -167,12 +258,8 @@ describe('useWebhookForm', () => {
await result.current.handleSave(formData); await result.current.handleSave(formData);
}); });
expect(mockCreateOneRecord).toHaveBeenCalledWith({ expect(mockEnqueueSuccessSnackBar).toHaveBeenCalledWith({
id: expect.any(String), message: 'Webhook https://test.com/webhook created successfully',
targetUrl: 'https://test.com/webhook',
description: 'Test webhook',
operations: ['person.created', 'company.updated'],
secret: 'test-secret',
}); });
}); });
}); });
@ -181,20 +268,29 @@ describe('useWebhookForm', () => {
const webhookId = 'test-webhook-id'; const webhookId = 'test-webhook-id';
it('should initialize correctly in edit mode', () => { it('should initialize correctly in edit mode', () => {
const mocks = [createGetWebhookMock(webhookId)];
const { result } = renderHook( const { result } = renderHook(
() => () =>
useWebhookForm({ useWebhookForm({
mode: WebhookFormMode.Edit, mode: WebhookFormMode.Edit,
webhookId, webhookId,
}), }),
{ wrapper: Wrapper }, {
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
); );
expect(result.current.isCreationMode).toBe(false); expect(result.current.isCreationMode).toBe(false);
}); });
it('should handle webhook update successfully', async () => { it('should handle webhook update successfully', async () => {
mockUpdateOneRecord.mockResolvedValue({}); const mocks = [
createGetWebhookMock(webhookId),
createSuccessfulUpdateMock(webhookId),
];
const { result } = renderHook( const { result } = renderHook(
() => () =>
@ -202,7 +298,11 @@ describe('useWebhookForm', () => {
mode: WebhookFormMode.Edit, mode: WebhookFormMode.Edit,
webhookId, webhookId,
}), }),
{ wrapper: Wrapper }, {
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
); );
const formData = { const formData = {
@ -216,22 +316,30 @@ describe('useWebhookForm', () => {
await result.current.handleSave(formData); await result.current.handleSave(formData);
}); });
expect(mockUpdateOneRecord).toHaveBeenCalledWith({ expect(mockEnqueueSuccessSnackBar).toHaveBeenCalledWith({
idToUpdate: webhookId, message: 'Webhook https://updated.com/webhook updated successfully',
updateOneRecordInput: {
targetUrl: 'https://updated.com/webhook',
description: 'Updated webhook',
operations: ['person.updated'],
secret: 'updated-secret',
},
}); });
}); });
it('should handle update errors', async () => { it('should handle update errors', async () => {
const error = new ApolloError({ const getWebhookMock = createGetWebhookMock(webhookId);
graphQLErrors: [{ message: 'Update failed' }], const updateErrorMock = {
}); request: {
mockUpdateOneRecord.mockRejectedValue(error); query: UPDATE_WEBHOOK,
variables: {
input: {
id: webhookId,
targetUrl: 'https://test.com/webhook',
operations: ['person.created'],
description: 'Test webhook',
secret: 'test-secret',
},
},
},
error: new Error('Update failed'),
};
const mocks = [getWebhookMock, updateErrorMock];
const { result } = renderHook( const { result } = renderHook(
() => () =>
@ -239,7 +347,11 @@ describe('useWebhookForm', () => {
mode: WebhookFormMode.Edit, mode: WebhookFormMode.Edit,
webhookId, webhookId,
}), }),
{ wrapper: Wrapper }, {
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
); );
const formData = { const formData = {
@ -254,7 +366,7 @@ describe('useWebhookForm', () => {
}); });
expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({ expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({
apolloError: error, apolloError: expect.any(Error),
}); });
}); });
}); });
@ -263,7 +375,7 @@ describe('useWebhookForm', () => {
it('should update operations correctly', () => { it('should update operations correctly', () => {
const { result } = renderHook( const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }), () => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper }, { wrapper: ({ children }) => <Wrapper>{children}</Wrapper> },
); );
act(() => { act(() => {
@ -277,7 +389,7 @@ describe('useWebhookForm', () => {
it('should remove operations correctly', () => { it('should remove operations correctly', () => {
const { result } = renderHook( const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }), () => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper }, { wrapper: ({ children }) => <Wrapper>{children}</Wrapper> },
); );
act(() => { act(() => {
@ -305,7 +417,10 @@ describe('useWebhookForm', () => {
const webhookId = 'test-webhook-id'; const webhookId = 'test-webhook-id';
it('should delete webhook successfully', async () => { it('should delete webhook successfully', async () => {
mockDeleteOneRecord.mockResolvedValue({}); const mocks = [
createGetWebhookMock(webhookId),
createSuccessfulDeleteMock(webhookId),
];
const { result } = renderHook( const { result } = renderHook(
() => () =>
@ -313,14 +428,17 @@ describe('useWebhookForm', () => {
mode: WebhookFormMode.Edit, mode: WebhookFormMode.Edit,
webhookId, webhookId,
}), }),
{ wrapper: Wrapper }, {
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
); );
await act(async () => { await act(async () => {
await result.current.deleteWebhook(); await result.current.handleDelete();
}); });
expect(mockDeleteOneRecord).toHaveBeenCalledWith(webhookId);
expect(mockEnqueueSuccessSnackBar).toHaveBeenCalledWith({ expect(mockEnqueueSuccessSnackBar).toHaveBeenCalledWith({
message: 'Webhook deleted successfully', message: 'Webhook deleted successfully',
}); });
@ -329,11 +447,11 @@ describe('useWebhookForm', () => {
it('should handle deletion without webhookId', async () => { it('should handle deletion without webhookId', async () => {
const { result } = renderHook( const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }), () => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper }, { wrapper: ({ children }) => <Wrapper>{children}</Wrapper> },
); );
await act(async () => { await act(async () => {
await result.current.deleteWebhook(); await result.current.handleDelete();
}); });
expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({ expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({
@ -342,10 +460,19 @@ describe('useWebhookForm', () => {
}); });
it('should handle deletion errors', async () => { it('should handle deletion errors', async () => {
const error = new ApolloError({ const errorMock = {
graphQLErrors: [{ message: 'Deletion failed' }], request: {
}); query: DELETE_WEBHOOK,
mockDeleteOneRecord.mockRejectedValue(error); variables: {
input: {
id: webhookId,
},
},
},
error: new Error('Deletion failed'),
};
const mocks = [createGetWebhookMock(webhookId), errorMock];
const { result } = renderHook( const { result } = renderHook(
() => () =>
@ -353,15 +480,19 @@ describe('useWebhookForm', () => {
mode: WebhookFormMode.Edit, mode: WebhookFormMode.Edit,
webhookId, webhookId,
}), }),
{ wrapper: Wrapper }, {
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
); );
await act(async () => { await act(async () => {
await result.current.deleteWebhook(); await result.current.handleDelete();
}); });
expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({ expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({
apolloError: error, apolloError: expect.any(Error),
}); });
}); });
}); });
@ -370,7 +501,7 @@ describe('useWebhookForm', () => {
it('should validate canSave property', () => { it('should validate canSave property', () => {
const { result } = renderHook( const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }), () => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper }, { wrapper: ({ children }) => <Wrapper>{children}</Wrapper> },
); );
// Initially canSave should be false (form is not valid) // Initially canSave should be false (form is not valid)

View File

@ -1,13 +1,13 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode'; import { WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode';
import { Webhook } from '@/settings/developers/types/webhook/Webhook'; import { addEmptyOperationIfNecessary } from '@/settings/developers/utils/addEmptyOperationIfNecessary';
import {
createWebhookCreateInput,
createWebhookUpdateInput,
} from '@/settings/developers/utils/createWebhookInput';
import { parseOperationsFromStrings } from '@/settings/developers/utils/parseOperationsFromStrings';
import { import {
webhookFormSchema, webhookFormSchema,
WebhookFormValues, WebhookFormValues,
@ -16,100 +16,66 @@ import { SettingsPath } from '@/types/SettingsPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client'; import { ApolloError } from '@apollo/client';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils'; import {
import { v4 } from 'uuid'; useCreateWebhookMutation,
useDeleteWebhookMutation,
useGetWebhookQuery,
useUpdateWebhookMutation,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { WEBHOOK_EMPTY_OPERATION } from '~/pages/settings/developers/webhooks/constants/WebhookEmptyOperation';
import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType';
type UseWebhookFormProps = { type UseWebhookFormProps = {
webhookId?: string; webhookId?: string;
mode: WebhookFormMode; mode: WebhookFormMode;
}; };
const DEFAULT_FORM_VALUES: WebhookFormValues = {
targetUrl: '',
description: '',
operations: [{ object: '*', action: '*' }],
secret: '',
};
export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => { export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => {
const navigate = useNavigateSettings(); const navigate = useNavigateSettings();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const isCreationMode = mode === WebhookFormMode.Create; const isCreationMode = mode === WebhookFormMode.Create;
const { createOneRecord } = useCreateOneRecord<Webhook>({ const [createWebhook] = useCreateWebhookMutation();
objectNameSingular: CoreObjectNameSingular.Webhook, const [updateWebhook] = useUpdateWebhookMutation();
}); const [deleteWebhook] = useDeleteWebhookMutation();
const { updateOneRecord } = useUpdateOneRecord<Webhook>({
objectNameSingular: CoreObjectNameSingular.Webhook,
});
const { deleteOneRecord: deleteOneWebhook } = useDeleteOneRecord({
objectNameSingular: CoreObjectNameSingular.Webhook,
});
const formConfig = useForm<WebhookFormValues>({ const formConfig = useForm<WebhookFormValues>({
mode: isCreationMode ? 'onSubmit' : 'onTouched', mode: isCreationMode ? 'onSubmit' : 'onTouched',
resolver: zodResolver(webhookFormSchema), resolver: zodResolver(webhookFormSchema),
defaultValues: { defaultValues: DEFAULT_FORM_VALUES,
targetUrl: '',
description: '',
operations: [
{
object: '*',
action: '*',
},
],
secret: '',
},
}); });
const addEmptyOperationIfNecessary = ( const { loading, error } = useGetWebhookQuery({
newOperations: WebhookOperationType[], skip: isCreationMode || !webhookId,
): WebhookOperationType[] => { variables: {
if ( input: { id: webhookId || '' },
!newOperations.some((op) => op.object === '*' && op.action === '*') && },
!newOperations.some((op) => op.object === null)
) {
return [...newOperations, WEBHOOK_EMPTY_OPERATION];
}
return newOperations;
};
const cleanAndFormatOperations = (operations: WebhookOperationType[]) => {
return Array.from(
new Set(
operations
.filter((op) => isDefined(op.object) && isDefined(op.action))
.map((op) => `${op.object}.${op.action}`),
),
);
};
const { loading, error } = useFindOneRecord({
skip: isCreationMode,
objectNameSingular: CoreObjectNameSingular.Webhook,
objectRecordId: webhookId || '',
onCompleted: (data) => { onCompleted: (data) => {
if (!data) return; const webhook = data.webhook;
if (!webhook) return;
const baseOperations = data?.operations const baseOperations = webhook?.operations?.length
? data.operations.map((op: string) => { ? parseOperationsFromStrings(webhook.operations)
const [object, action] = op.split('.'); : [];
return { object, action };
})
: data?.operation
? [
{
object: data.operation.split('.')[0],
action: data.operation.split('.')[1],
},
]
: [];
const operations = addEmptyOperationIfNecessary(baseOperations); const operations = addEmptyOperationIfNecessary(baseOperations);
formConfig.reset({ formConfig.reset({
targetUrl: data.targetUrl || '', targetUrl: webhook.targetUrl || '',
description: data.description || '', description: webhook.description || '',
operations, operations,
secret: data.secret || '', secret: webhook.secret || '',
});
},
onError: () => {
enqueueErrorSnackBar({
message: t`Failed to load webhook`,
}); });
}, },
}); });
@ -121,19 +87,9 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => {
const handleCreate = async (formValues: WebhookFormValues) => { const handleCreate = async (formValues: WebhookFormValues) => {
try { try {
const cleanedOperations = cleanAndFormatOperations(formValues.operations); const input = createWebhookCreateInput(formValues);
const { data } = await createWebhook({ variables: { input } });
const webhookData = { const createdWebhook = data?.createWebhook;
targetUrl: formValues.targetUrl.trim(),
operations: cleanedOperations,
description: formValues.description,
secret: formValues.secret,
};
const createdWebhook = await createOneRecord({
id: v4(),
...webhookData,
});
const targetUrl = createdWebhook?.targetUrl const targetUrl = createdWebhook?.targetUrl
? `${createdWebhook?.targetUrl}` ? `${createdWebhook?.targetUrl}`
@ -163,23 +119,15 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => {
} }
try { try {
const cleanedOperations = cleanAndFormatOperations(formValues.operations); const input = createWebhookUpdateInput(formValues, webhookId);
const { data } = await updateWebhook({ variables: { input } });
const webhookData = { const updatedWebhook = data?.updateWebhook;
targetUrl: formValues.targetUrl.trim(),
operations: cleanedOperations,
description: formValues.description,
secret: formValues.secret,
};
await updateOneRecord({
idToUpdate: webhookId,
updateOneRecordInput: webhookData,
});
formConfig.reset(formValues); formConfig.reset(formValues);
const targetUrl = webhookData.targetUrl ? `${webhookData.targetUrl}` : ''; const targetUrl = updatedWebhook?.targetUrl
? `${updatedWebhook.targetUrl}`
: '';
enqueueSuccessSnackBar({ enqueueSuccessSnackBar({
message: t`Webhook ${targetUrl} updated successfully`, message: t`Webhook ${targetUrl} updated successfully`,
@ -224,7 +172,7 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => {
); );
}; };
const deleteWebhook = async () => { const handleDelete = async () => {
if (!webhookId) { if (!webhookId) {
enqueueErrorSnackBar({ enqueueErrorSnackBar({
message: t`Webhook ID is required for deletion`, message: t`Webhook ID is required for deletion`,
@ -233,7 +181,9 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => {
} }
try { try {
await deleteOneWebhook(webhookId); await deleteWebhook({
variables: { input: { id: webhookId } },
});
enqueueSuccessSnackBar({ enqueueSuccessSnackBar({
message: t`Webhook deleted successfully`, message: t`Webhook deleted successfully`,
}); });
@ -253,7 +203,7 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => {
handleSave, handleSave,
updateOperation, updateOperation,
removeOperation, removeOperation,
deleteWebhook, handleDelete,
isCreationMode, isCreationMode,
error, error,
}; };

View File

@ -1,6 +0,0 @@
export type ApiFieldItem = {
id: string;
name: string;
type: 'internal' | 'published';
expiration: string;
};

View File

@ -1,10 +0,0 @@
export type ApiKey = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
name: string;
expiresAt: string;
revokedAt: string | null;
__typename: 'ApiKey';
};

View File

@ -1,8 +0,0 @@
export type Webhook = {
id: string;
targetUrl: string;
description?: string;
operations: string[];
secret?: string;
__typename: 'Webhook';
};

View File

@ -0,0 +1,67 @@
import { WEBHOOK_EMPTY_OPERATION } from '~/pages/settings/developers/webhooks/constants/WebhookEmptyOperation';
import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType';
import { addEmptyOperationIfNecessary } from '../addEmptyOperationIfNecessary';
describe('addEmptyOperationIfNecessary', () => {
it('should add empty operation when no wildcard or null object operations exist', () => {
const operations: WebhookOperationType[] = [
{ object: 'person', action: 'created' },
{ object: 'company', action: 'updated' },
];
const result = addEmptyOperationIfNecessary(operations);
expect(result).toEqual([
{ object: 'person', action: 'created' },
{ object: 'company', action: 'updated' },
WEBHOOK_EMPTY_OPERATION,
]);
});
it('should not add empty operation when wildcard operation exists', () => {
const operations: WebhookOperationType[] = [
{ object: '*', action: '*' },
{ object: 'person', action: 'created' },
];
const result = addEmptyOperationIfNecessary(operations);
expect(result).toEqual([
{ object: '*', action: '*' },
{ object: 'person', action: 'created' },
]);
});
it('should not add empty operation when null object operation exists', () => {
const operations: WebhookOperationType[] = [
{ object: 'person', action: 'created' },
{ object: null, action: 'test' },
];
const result = addEmptyOperationIfNecessary(operations);
expect(result).toEqual([
{ object: 'person', action: 'created' },
{ object: null, action: 'test' },
]);
});
it('should handle empty array by adding empty operation', () => {
const operations: WebhookOperationType[] = [];
const result = addEmptyOperationIfNecessary(operations);
expect(result).toEqual([WEBHOOK_EMPTY_OPERATION]);
});
it('should not modify original array', () => {
const operations: WebhookOperationType[] = [
{ object: 'person', action: 'created' },
];
const originalLength = operations.length;
addEmptyOperationIfNecessary(operations);
expect(operations.length).toBe(originalLength);
});
});

View File

@ -0,0 +1,47 @@
import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType';
import { cleanAndFormatOperations } from '../cleanAndFormatOperations';
describe('cleanAndFormatOperations', () => {
it('should filter out operations with null object values', () => {
const operations: WebhookOperationType[] = [
{ object: 'person', action: 'created' },
{ object: null, action: 'test' },
{ object: 'person', action: 'updated' },
];
const result = cleanAndFormatOperations(operations);
expect(result).toEqual(['person.created', 'person.updated']);
});
it('should remove duplicate operations', () => {
const operations: WebhookOperationType[] = [
{ object: 'person', action: 'created' },
{ object: 'person', action: 'created' },
{ object: 'company', action: 'updated' },
];
const result = cleanAndFormatOperations(operations);
expect(result).toEqual(['person.created', 'company.updated']);
});
it('should handle empty array', () => {
const operations: WebhookOperationType[] = [];
const result = cleanAndFormatOperations(operations);
expect(result).toEqual([]);
});
it('should handle wildcard operations', () => {
const operations: WebhookOperationType[] = [
{ object: '*', action: '*' },
{ object: 'person', action: 'created' },
];
const result = cleanAndFormatOperations(operations);
expect(result).toEqual(['*.*', 'person.created']);
});
});

View File

@ -0,0 +1,71 @@
import { WebhookFormValues } from '@/settings/developers/validation-schemas/webhookFormSchema';
import {
createWebhookCreateInput,
createWebhookUpdateInput,
} from '../createWebhookInput';
describe('createWebhookInput', () => {
const mockFormValues: WebhookFormValues = {
targetUrl: ' https://test.com/webhook ',
description: 'Test webhook',
operations: [
{ object: 'person', action: 'created' },
{ object: 'person', action: 'created' }, // duplicate
{ object: 'company', action: 'updated' },
{ object: null, action: 'test' }, // should be filtered out
],
secret: 'test-secret',
};
describe('createWebhookCreateInput', () => {
it('should create input for webhook creation', () => {
const result = createWebhookCreateInput(mockFormValues);
expect(result).toEqual({
targetUrl: 'https://test.com/webhook',
operations: ['person.created', 'company.updated'],
description: 'Test webhook',
secret: 'test-secret',
});
});
it('should trim targetUrl', () => {
const formValues: WebhookFormValues = {
...mockFormValues,
targetUrl: ' https://example.com ',
};
const result = createWebhookCreateInput(formValues);
expect(result.targetUrl).toBe('https://example.com');
});
});
describe('createWebhookUpdateInput', () => {
it('should create input for webhook update with id', () => {
const webhookId = 'test-webhook-id';
const result = createWebhookUpdateInput(mockFormValues, webhookId);
expect(result).toEqual({
id: 'test-webhook-id',
targetUrl: 'https://test.com/webhook',
operations: ['person.created', 'company.updated'],
description: 'Test webhook',
secret: 'test-secret',
});
});
it('should trim targetUrl and include id', () => {
const formValues: WebhookFormValues = {
...mockFormValues,
targetUrl: ' https://example.com ',
};
const webhookId = 'test-webhook-id';
const result = createWebhookUpdateInput(formValues, webhookId);
expect(result.targetUrl).toBe('https://example.com');
expect(result.id).toBe('test-webhook-id');
});
});
});

View File

@ -0,0 +1,42 @@
import { parseOperationsFromStrings } from '../parseOperationsFromStrings';
describe('parseOperationsFromStrings', () => {
it('should parse operation strings into object/action pairs', () => {
const operations = ['person.created', 'company.updated', 'lead.deleted'];
const result = parseOperationsFromStrings(operations);
expect(result).toEqual([
{ object: 'person', action: 'created' },
{ object: 'company', action: 'updated' },
{ object: 'lead', action: 'deleted' },
]);
});
it('should handle wildcard operations', () => {
const operations = ['*.*', 'person.created'];
const result = parseOperationsFromStrings(operations);
expect(result).toEqual([
{ object: '*', action: '*' },
{ object: 'person', action: 'created' },
]);
});
it('should handle empty array', () => {
const operations: string[] = [];
const result = parseOperationsFromStrings(operations);
expect(result).toEqual([]);
});
it('should handle operations with multiple dots by taking first two parts', () => {
const operations = ['person.created.test'];
const result = parseOperationsFromStrings(operations);
expect(result).toEqual([{ object: 'person', action: 'created' }]);
});
});

View File

@ -0,0 +1,14 @@
import { WEBHOOK_EMPTY_OPERATION } from '~/pages/settings/developers/webhooks/constants/WebhookEmptyOperation';
import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType';
export const addEmptyOperationIfNecessary = (
newOperations: WebhookOperationType[],
): WebhookOperationType[] => {
if (
!newOperations.some((op) => op.object === '*' && op.action === '*') &&
!newOperations.some((op) => op.object === null)
) {
return [...newOperations, WEBHOOK_EMPTY_OPERATION];
}
return newOperations;
};

View File

@ -0,0 +1,15 @@
import { isDefined } from 'twenty-shared/utils';
import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType';
export const cleanAndFormatOperations = (
operations: WebhookOperationType[],
) => {
return Array.from(
new Set(
operations
.filter((op) => isDefined(op.object) && isDefined(op.action))
.map((op) => `${op.object}.${op.action}`),
),
);
};

View File

@ -0,0 +1,28 @@
import { WebhookFormValues } from '@/settings/developers/validation-schemas/webhookFormSchema';
import { cleanAndFormatOperations } from './cleanAndFormatOperations';
export const createWebhookCreateInput = (formValues: WebhookFormValues) => {
const cleanedOperations = cleanAndFormatOperations(formValues.operations);
return {
targetUrl: formValues.targetUrl.trim(),
operations: cleanedOperations,
description: formValues.description,
secret: formValues.secret,
};
};
export const createWebhookUpdateInput = (
formValues: WebhookFormValues,
webhookId: string,
) => {
const cleanedOperations = cleanAndFormatOperations(formValues.operations);
return {
id: webhookId,
targetUrl: formValues.targetUrl.trim(),
operations: cleanedOperations,
description: formValues.description,
secret: formValues.secret,
};
};

View File

@ -2,8 +2,6 @@ import { isNonEmptyString } from '@sniptt/guards';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { NEVER_EXPIRE_DELTA_IN_YEARS } from '@/settings/developers/constants/NeverExpireDeltaInYears'; import { NEVER_EXPIRE_DELTA_IN_YEARS } from '@/settings/developers/constants/NeverExpireDeltaInYears';
import { ApiFieldItem } from '@/settings/developers/types/api-key/ApiFieldItem';
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
import { beautifyDateDiff } from '~/utils/date-utils'; import { beautifyDateDiff } from '~/utils/date-utils';
export const doesNeverExpire = (expiresAt: string) => { export const doesNeverExpire = (expiresAt: string) => {
@ -28,16 +26,3 @@ export const formatExpiration = (
} }
return withExpiresMention ? `Expires in ${dateDiff}` : `In ${dateDiff}`; return withExpiresMention ? `Expires in ${dateDiff}` : `In ${dateDiff}`;
}; };
export const formatExpirations = (
apiKeys: Array<Pick<ApiKey, 'id' | 'name' | 'expiresAt'>>,
): ApiFieldItem[] => {
return apiKeys.map(({ id, name, expiresAt }) => {
return {
id,
name,
expiration: formatExpiration(expiresAt || null),
type: 'internal',
};
});
};

View File

@ -0,0 +1,10 @@
import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType';
export const parseOperationsFromStrings = (
operations: string[],
): WebhookOperationType[] => {
return operations.map((op: string) => {
const [object, action] = op.split('.');
return { object, action };
});
};

View File

@ -1,6 +1,5 @@
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { fireEvent, userEvent, within } from '@storybook/test'; import { fireEvent, userEvent, within } from '@storybook/test';
import { HttpResponse, graphql } from 'msw';
import { SettingsDevelopersApiKeyDetail } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail'; import { SettingsDevelopersApiKeyDetail } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail';
import { import {
@ -21,26 +20,7 @@ const meta: Meta<PageDecoratorArgs> = {
}, },
}, },
parameters: { parameters: {
msw: { msw: graphqlMocks,
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',
},
},
});
}),
],
},
}, },
}; };
export default meta; export default meta;
@ -50,14 +30,14 @@ export type Story = StoryObj<typeof SettingsDevelopersApiKeyDetail>;
export const Default: Story = { export const Default: Story = {
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
await canvas.findByText('sfsfdsf', undefined, { timeout: 3000 }); await canvas.findByText('Zapier Integration', undefined, { timeout: 3000 });
}, },
}; };
export const RegenerateApiKey: Story = { export const RegenerateApiKey: Story = {
play: async ({ step }) => { play: async ({ step }) => {
const canvas = within(document.body); 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')); await userEvent.click(await canvas.findByText('Regenerate Key'));
@ -85,7 +65,7 @@ export const RegenerateApiKey: Story = {
export const DeleteApiKey: Story = { export const DeleteApiKey: Story = {
play: async ({ canvasElement, step }) => { play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement); 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')); await userEvent.click(await canvas.findByText('Delete'));

View File

@ -27,7 +27,7 @@ export type Story = StoryObj<typeof SettingsDevelopersWebhookNew>;
export const Default: Story = { export const Default: Story = {
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
await canvas.findByText('New Webhook', undefined, { timeout: 10000 }); await canvas.findByText('New Webhook', undefined, { timeout: 3000 });
await canvas.findByText( await canvas.findByText(
'We will send POST requests to this endpoint for every new event', '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 { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test'; import { expect, within } from '@storybook/test';
import { import {
PageDecorator, PageDecorator,
@ -28,11 +28,20 @@ export type Story = StoryObj<typeof SettingsDevelopersWebhookDetail>;
export const Default: Story = { export const Default: Story = {
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
await canvas.findByText( await canvas.findByDisplayValue(
'We will send POST requests to this endpoint for every new event', 'https://api.slackbot.io/webhooks/twenty',
undefined, 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'); await canvas.findByText('Delete this webhook');
}, },
}; };

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import { TRACK_ANALYTICS } from '@/analytics/graphql/queries/track';
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries'; import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser'; import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { mockedApiKeys } from '~/testing/mock-data/api-keys';
import { import {
getCompaniesMock, getCompaniesMock,
getCompanyDuplicateMock, getCompanyDuplicateMock,
@ -650,26 +651,6 @@ export const graphqlMocks = {
}); });
}, },
), ),
graphql.query<GraphQLQuery, { objectRecordId: string }>(
'FindOneWebhook',
({ variables: { objectRecordId } }) => {
return HttpResponse.json({
data: {
webhook: {
__typename: 'Webhook',
id: objectRecordId,
createdAt: '2021-08-27T12:00:00Z',
updatedAt: '2021-08-27T12:00:00Z',
deletedAt: null,
targetUrl: 'https://example.com/webhook',
description: 'A Sample Description',
operations: ['*.created', '*.updated'],
secret: 'sample-secret',
},
},
});
},
),
graphql.query('FindManyWorkflows', () => { graphql.query('FindManyWorkflows', () => {
return HttpResponse.json({ return HttpResponse.json({
data: workflowQueryResult, data: workflowQueryResult,
@ -711,5 +692,64 @@ export const graphqlMocks = {
{ status: 200 }, { status: 200 },
); );
}), }),
metadataGraphql.query('GetApiKeys', () => {
return HttpResponse.json({
data: {
apiKeys: mockedApiKeys.map((apiKey) => ({
__typename: 'ApiKey',
...apiKey,
revokedAt: null,
})),
},
});
}),
metadataGraphql.query('GetApiKey', ({ variables }) => {
const apiKeyId = variables.input?.id;
const apiKey = mockedApiKeys.find((key) => key.id === apiKeyId);
return HttpResponse.json({
data: {
apiKey: apiKey
? {
__typename: 'ApiKey',
...apiKey,
revokedAt: null,
}
: null,
},
});
}),
metadataGraphql.query('GetWebhooks', () => {
return HttpResponse.json({
data: {
webhooks: [
{
__typename: 'Webhook',
id: '1234',
targetUrl: 'https://api.slackbot.io/webhooks/twenty',
operations: ['*.created', '*.updated'],
description: 'Slack notifications for lead updates',
secret: 'sample-secret',
},
],
},
});
}),
metadataGraphql.query('GetWebhook', ({ variables }) => {
const webhookId = variables.input?.id;
return HttpResponse.json({
data: {
webhook: {
__typename: 'Webhook',
id: webhookId || '1234',
targetUrl: 'https://api.slackbot.io/webhooks/twenty',
operations: ['*.created', '*.updated'],
description: 'Slack notifications for lead updates',
secret: 'sample-secret',
},
},
});
}),
], ],
}; };

View File

@ -1,4 +1,4 @@
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey'; import { ApiKey } from '~/generated-metadata/graphql';
type MockedApiKey = Pick< type MockedApiKey = Pick<
ApiKey, ApiKey,

View File

@ -1,13 +1,13 @@
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
declare module 'express-serve-static-core' { declare module 'express-serve-static-core' {
interface Request { interface Request {
user?: User | null; user?: User | null;
apiKey?: ApiKeyWorkspaceEntity | null; apiKey?: ApiKey | null;
userWorkspace?: UserWorkspace; userWorkspace?: UserWorkspace;
workspace?: Workspace; workspace?: Workspace;
workspaceId?: string; workspaceId?: string;

View File

@ -1,4 +1,5 @@
import 'jest'; import 'jest';
import { DataSource } from 'typeorm';
declare module '@jest/types' { declare module '@jest/types' {
namespace Config { namespace Config {
@ -10,6 +11,7 @@ declare module '@jest/types' {
MEMBER_ACCESS_TOKEN: string; MEMBER_ACCESS_TOKEN: string;
GUEST_ACCESS_TOKEN: string; GUEST_ACCESS_TOKEN: string;
API_KEY_ACCESS_TOKEN: string; API_KEY_ACCESS_TOKEN: string;
testDataSource?: DataSource;
} }
} }
} }
@ -23,6 +25,7 @@ declare global {
const GUEST_ACCESS_TOKEN: string; const GUEST_ACCESS_TOKEN: string;
const API_KEY_ACCESS_TOKEN: string; const API_KEY_ACCESS_TOKEN: string;
const WORKSPACE_AGNOSTIC_TOKEN: string; const WORKSPACE_AGNOSTIC_TOKEN: string;
const testDataSource: DataSource;
} }
export {}; export {};

View File

@ -1,10 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CronRegisterAllCommand } from 'src/database/commands/cron-register-all.command'; import { CronRegisterAllCommand } from 'src/database/commands/cron-register-all.command';
import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command';
import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question'; import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question';
import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/upgrade-version-command.module'; import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/upgrade-version-command.module';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
@ -15,6 +15,8 @@ import { CalendarEventImportManagerModule } from 'src/modules/calendar/calendar-
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module'; import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/automated-trigger/automated-trigger.module'; import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/automated-trigger/automated-trigger.module';
import { DataSeedWorkspaceCommand } from './data-seed-dev-workspace.command';
@Module({ @Module({
imports: [ imports: [
UpgradeVersionCommandModule, UpgradeVersionCommandModule,
@ -24,7 +26,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
CalendarEventImportManagerModule, CalendarEventImportManagerModule,
AutomatedTriggerModule, AutomatedTriggerModule,
// Only needed for the data seed command // Data seeding dependencies
TypeORMModule, TypeORMModule,
FieldMetadataModule, FieldMetadataModule,
ObjectMetadataModule, ObjectMetadataModule,
@ -32,6 +34,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
WorkspaceManagerModule, WorkspaceManagerModule,
DataSourceModule, DataSourceModule,
WorkspaceCacheStorageModule, WorkspaceCacheStorageModule,
ApiKeyModule,
], ],
providers: [ providers: [
DataSeedWorkspaceCommand, DataSeedWorkspaceCommand,

View File

@ -0,0 +1,212 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service';
import { Webhook } from 'src/engine/core-modules/webhook/webhook.entity';
import { WebhookService } from 'src/engine/core-modules/webhook/webhook.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity';
@Command({
name: 'upgrade:1-3:migrate-api-keys-webhooks-to-core',
description:
'Migrate API keys and webhooks from workspace schemas to core schema',
})
export class MigrateApiKeysWebhooksToCoreCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(ApiKey, 'core')
private readonly coreApiKeyRepository: Repository<ApiKey>,
@InjectRepository(Webhook, 'core')
private readonly coreWebhookRepository: Repository<Webhook>,
private readonly apiKeyService: ApiKeyService,
private readonly webhookService: WebhookService,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
index,
total,
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(
`Migrating API keys and webhooks for workspace ${workspaceId} ${index + 1}/${total}`,
);
try {
await this.migrateApiKeys(workspaceId, options.dryRun);
await this.migrateWebhooks(workspaceId, options.dryRun);
this.logger.log(
`Successfully migrated API keys and webhooks for workspace ${workspaceId}`,
);
} catch (error) {
this.logger.error(
`Failed to migrate API keys and webhooks for workspace ${workspaceId}: ${error.message}`,
);
throw error;
}
}
private async migrateApiKeys(
workspaceId: string,
dryRun?: boolean,
): Promise<void> {
const workspaceApiKeyRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ApiKeyWorkspaceEntity>(
workspaceId,
'apiKey',
{ shouldBypassPermissionChecks: true },
);
const workspaceApiKeys = await workspaceApiKeyRepository.find({
withDeleted: true,
});
if (workspaceApiKeys.length === 0) {
this.logger.log(`No API keys to migrate for workspace ${workspaceId}`);
return;
}
this.logger.log(
`${dryRun ? 'DRY RUN: ' : ''}Found ${workspaceApiKeys.length} API keys to migrate for workspace ${workspaceId}`,
);
if (dryRun) {
workspaceApiKeys.forEach((apiKey) => {
const deletedStatus = apiKey.deletedAt ? ' (DELETED)' : '';
this.logger.log(
`DRY RUN: Would migrate API key ${apiKey.id} (${apiKey.name})${deletedStatus} from workspace ${workspaceId}`,
);
});
return;
}
const existingCoreApiKeys = await this.coreApiKeyRepository.find({
where: { workspaceId },
select: ['id'],
withDeleted: true,
});
const existingApiKeyIds = new Set(existingCoreApiKeys.map((ak) => ak.id));
for (const workspaceApiKey of workspaceApiKeys) {
if (existingApiKeyIds.has(workspaceApiKey.id)) {
this.logger.warn(
`API key ${workspaceApiKey.id} already exists in core schema for workspace ${workspaceId}, skipping`,
);
continue;
}
await this.apiKeyService.create({
id: workspaceApiKey.id,
name: workspaceApiKey.name,
expiresAt: workspaceApiKey.expiresAt,
revokedAt: workspaceApiKey.revokedAt
? new Date(workspaceApiKey.revokedAt)
: workspaceApiKey.deletedAt
? new Date(workspaceApiKey.deletedAt)
: undefined,
workspaceId,
createdAt: new Date(workspaceApiKey.createdAt),
updatedAt: new Date(workspaceApiKey.updatedAt),
});
const deletedStatus = workspaceApiKey.deletedAt ? ' (DELETED)' : '';
this.logger.log(
`Migrated API key ${workspaceApiKey.id} (${workspaceApiKey.name})${deletedStatus} to core schema`,
);
}
}
private async migrateWebhooks(
workspaceId: string,
dryRun?: boolean,
): Promise<void> {
const workspaceWebhookRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WebhookWorkspaceEntity>(
workspaceId,
'webhook',
{ shouldBypassPermissionChecks: true },
);
const workspaceWebhooks = await workspaceWebhookRepository.find({
withDeleted: true,
});
if (workspaceWebhooks.length === 0) {
this.logger.log(`No webhooks to migrate for workspace ${workspaceId}`);
return;
}
this.logger.log(
`${dryRun ? 'DRY RUN: ' : ''}Found ${workspaceWebhooks.length} webhooks to migrate for workspace ${workspaceId}`,
);
if (dryRun) {
workspaceWebhooks.forEach((webhook) => {
const deletedStatus = webhook.deletedAt ? ' (DELETED)' : '';
this.logger.log(
`DRY RUN: Would migrate webhook ${webhook.id} (${webhook.targetUrl})${deletedStatus} from workspace ${workspaceId}`,
);
});
return;
}
const existingCoreWebhooks = await this.coreWebhookRepository.find({
where: { workspaceId },
select: ['id'],
withDeleted: true,
});
const existingWebhookIds = new Set(existingCoreWebhooks.map((wh) => wh.id));
for (const workspaceWebhook of workspaceWebhooks) {
if (existingWebhookIds.has(workspaceWebhook.id)) {
this.logger.warn(
`Webhook ${workspaceWebhook.id} already exists in core schema for workspace ${workspaceId}, skipping`,
);
continue;
}
await this.webhookService.create({
id: workspaceWebhook.id,
targetUrl: workspaceWebhook.targetUrl,
operations: workspaceWebhook.operations,
description: workspaceWebhook.description,
secret: workspaceWebhook.secret,
workspaceId,
createdAt: new Date(workspaceWebhook.createdAt),
updatedAt: new Date(workspaceWebhook.updatedAt),
deletedAt: workspaceWebhook.deletedAt
? new Date(workspaceWebhook.deletedAt)
: undefined,
});
const deletedStatus = workspaceWebhook.deletedAt ? ' (DELETED)' : '';
this.logger.log(
`Migrated webhook ${workspaceWebhook.id} (${workspaceWebhook.targetUrl})${deletedStatus} to core schema`,
);
}
}
}

View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MigrateApiKeysWebhooksToCoreCommand } from 'src/database/commands/upgrade-version-command/1-3/1-3-migrate-api-keys-webhooks-to-core.command';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { Webhook } from 'src/engine/core-modules/webhook/webhook.entity';
import { WebhookModule } from 'src/engine/core-modules/webhook/webhook.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace, ApiKey, Webhook], 'core'),
WorkspaceDataSourceModule,
ApiKeyModule,
WebhookModule,
],
providers: [MigrateApiKeysWebhooksToCoreCommand],
exports: [MigrateApiKeysWebhooksToCoreCommand],
})
export class V1_3_UpgradeVersionCommandModule {}

View File

@ -5,6 +5,7 @@ import { V0_54_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
import { V0_55_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/0-55/0-55-upgrade-version-command.module'; import { V0_55_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/0-55/0-55-upgrade-version-command.module';
import { V1_1_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-1/1-1-upgrade-version-command.module'; import { V1_1_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-1/1-1-upgrade-version-command.module';
import { V1_2_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-2/1-2-upgrade-version-command.module'; import { V1_2_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-2/1-2-upgrade-version-command.module';
import { V1_3_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-3/1-3-upgrade-version-command.module';
import { import {
DatabaseMigrationService, DatabaseMigrationService,
UpgradeCommand, UpgradeCommand,
@ -19,6 +20,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp
V0_55_UpgradeVersionCommandModule, V0_55_UpgradeVersionCommandModule,
V1_1_UpgradeVersionCommandModule, V1_1_UpgradeVersionCommandModule,
V1_2_UpgradeVersionCommandModule, V1_2_UpgradeVersionCommandModule,
V1_3_UpgradeVersionCommandModule,
WorkspaceSyncMetadataModule, WorkspaceSyncMetadataModule,
], ],
providers: [DatabaseMigrationService, UpgradeCommand], providers: [DatabaseMigrationService, UpgradeCommand],

View File

@ -24,6 +24,7 @@ import { DeduplicateIndexedFieldsCommand } from 'src/database/commands/upgrade-v
import { FixSchemaArrayTypeCommand } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-schema-array-type.command'; import { FixSchemaArrayTypeCommand } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-schema-array-type.command';
import { FixUpdateStandardFieldsIsLabelSyncedWithName } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-update-standard-field-is-label-synced-with-name.command'; import { FixUpdateStandardFieldsIsLabelSyncedWithName } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-update-standard-field-is-label-synced-with-name.command';
import { AddEnqueuedStatusToWorkflowRunCommand } from 'src/database/commands/upgrade-version-command/1-2/1-2-add-enqueued-status-to-workflow-run.command'; import { AddEnqueuedStatusToWorkflowRunCommand } from 'src/database/commands/upgrade-version-command/1-2/1-2-add-enqueued-status-to-workflow-run.command';
import { MigrateApiKeysWebhooksToCoreCommand } from 'src/database/commands/upgrade-version-command/1-3/1-3-migrate-api-keys-webhooks-to-core.command';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@ -147,6 +148,9 @@ export class UpgradeCommand extends UpgradeCommandRunner {
// 1.2 Commands // 1.2 Commands
protected readonly migrateWorkflowRunStatesCommand: MigrateWorkflowRunStatesCommand, protected readonly migrateWorkflowRunStatesCommand: MigrateWorkflowRunStatesCommand,
protected readonly addEnqueuedStatusToWorkflowRunCommand: AddEnqueuedStatusToWorkflowRunCommand, protected readonly addEnqueuedStatusToWorkflowRunCommand: AddEnqueuedStatusToWorkflowRunCommand,
// 1.3 Commands
protected readonly migrateApiKeysWebhooksToCoreCommand: MigrateApiKeysWebhooksToCoreCommand,
) { ) {
super( super(
workspaceRepository, workspaceRepository,
@ -200,6 +204,11 @@ export class UpgradeCommand extends UpgradeCommandRunner {
afterSyncMetadata: [this.migrateWorkflowRunStatesCommand], afterSyncMetadata: [this.migrateWorkflowRunStatesCommand],
}; };
const commands_130: VersionCommands = {
beforeSyncMetadata: [this.migrateApiKeysWebhooksToCoreCommand],
afterSyncMetadata: [],
};
this.allCommands = { this.allCommands = {
'0.53.0': commands_053, '0.53.0': commands_053,
'0.54.0': commands_054, '0.54.0': commands_054,
@ -208,6 +217,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
'1.0.0': commands_100, '1.0.0': commands_100,
'1.1.0': commands_110, '1.1.0': commands_110,
'1.2.0': commands_120, '1.2.0': commands_120,
'1.3.0': commands_130,
}; };
} }

View File

@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddApiKeysAndWebhookToCore1751690946522
implements MigrationInterface
{
name = 'AddApiKeysAndWebhookToCore1751690946522';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."apiKey" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "revokedAt" TIMESTAMP WITH TIME ZONE, "workspaceId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_2ae3a5e8e04fb402b2dc8d6ce4b" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_API_KEY_WORKSPACE_ID" ON "core"."apiKey" ("workspaceId") `,
);
await queryRunner.query(
`CREATE TABLE "core"."webhook" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "targetUrl" character varying NOT NULL, "operations" text array NOT NULL DEFAULT '{*.*}', "description" character varying, "secret" character varying NOT NULL, "workspaceId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_e6765510c2d078db49632b59020" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_WEBHOOK_WORKSPACE_ID" ON "core"."webhook" ("workspaceId") `,
);
await queryRunner.query(
`ALTER TABLE "core"."apiKey" ADD CONSTRAINT "FK_c8b3efa54a29aa873043e72fb1d" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."webhook" ADD CONSTRAINT "FK_597ab5e7de76f1836b8fd80d6b9" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."webhook" DROP CONSTRAINT "FK_597ab5e7de76f1836b8fd80d6b9"`,
);
await queryRunner.query(
`ALTER TABLE "core"."apiKey" DROP CONSTRAINT "FK_c8b3efa54a29aa873043e72fb1d"`,
);
await queryRunner.query(`DROP INDEX "core"."IDX_WEBHOOK_WORKSPACE_ID"`);
await queryRunner.query(`DROP TABLE "core"."webhook"`);
await queryRunner.query(`DROP INDEX "core"."IDX_API_KEY_WORKSPACE_ID"`);
await queryRunner.query(`DROP TABLE "core"."apiKey"`);
}
}

View File

@ -9,7 +9,6 @@ import { BlocklistQueryHookModule } from 'src/modules/blocklist/query-hooks/bloc
import { CalendarQueryHookModule } from 'src/modules/calendar/common/query-hooks/calendar-query-hook.module'; import { CalendarQueryHookModule } from 'src/modules/calendar/common/query-hooks/calendar-query-hook.module';
import { ConnectedAccountQueryHookModule } from 'src/modules/connected-account/query-hooks/connected-account-query-hook.module'; import { ConnectedAccountQueryHookModule } from 'src/modules/connected-account/query-hooks/connected-account-query-hook.module';
import { MessagingQueryHookModule } from 'src/modules/messaging/common/query-hooks/messaging-query-hook.module'; import { MessagingQueryHookModule } from 'src/modules/messaging/common/query-hooks/messaging-query-hook.module';
import { WebhookQueryHookModule } from 'src/modules/webhook/query-hooks/webhook-query-hook.module';
import { WorkspaceMemberQueryHookModule } from 'src/modules/workspace-member/query-hooks/workspace-member-query-hook.module'; import { WorkspaceMemberQueryHookModule } from 'src/modules/workspace-member/query-hooks/workspace-member-query-hook.module';
@Module({ @Module({
@ -18,7 +17,6 @@ import { WorkspaceMemberQueryHookModule } from 'src/modules/workspace-member/que
CalendarQueryHookModule, CalendarQueryHookModule,
ConnectedAccountQueryHookModule, ConnectedAccountQueryHookModule,
BlocklistQueryHookModule, BlocklistQueryHookModule,
WebhookQueryHookModule,
WorkspaceMemberQueryHookModule, WorkspaceMemberQueryHookModule,
DiscoveryModule, DiscoveryModule,
], ],

View File

@ -10,9 +10,17 @@ import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-res
import { RestoreOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory'; import { RestoreOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory';
import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory'; import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory';
import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service'; import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
import { getResolverName } from 'src/engine/utils/get-resolver-name.util'; import { getResolverName } from 'src/engine/utils/get-resolver-name.util';
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util';
import { CreateManyResolverFactory } from './factories/create-many-resolver.factory'; import { CreateManyResolverFactory } from './factories/create-many-resolver.factory';
import { CreateOneResolverFactory } from './factories/create-one-resolver.factory'; import { CreateOneResolverFactory } from './factories/create-one-resolver.factory';
@ -46,6 +54,7 @@ export class WorkspaceResolverFactory {
private readonly restoreManyResolverFactory: RestoreManyResolverFactory, private readonly restoreManyResolverFactory: RestoreManyResolverFactory,
private readonly destroyManyResolverFactory: DestroyManyResolverFactory, private readonly destroyManyResolverFactory: DestroyManyResolverFactory,
private readonly workspaceResolverBuilderService: WorkspaceResolverBuilderService, private readonly workspaceResolverBuilderService: WorkspaceResolverBuilderService,
private readonly featureFlagService: FeatureFlagService,
) {} ) {}
async create( async create(
@ -76,9 +85,44 @@ export class WorkspaceResolverFactory {
Mutation: {}, Mutation: {},
}; };
const workspaceId = authContext.workspace?.id;
if (!workspaceId) {
throw new AuthException(
'Unauthenticated',
AuthExceptionCode.UNAUTHENTICATED,
);
}
const workspaceFeatureFlagsMap =
await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspaceId);
for (const objectMetadata of Object.values(objectMetadataMaps.byId).filter( for (const objectMetadata of Object.values(objectMetadataMaps.byId).filter(
isDefined, isDefined,
)) { )) {
const workspaceEntity = standardObjectMetadataDefinitions.find(
(entity) => {
const entityMetadata = metadataArgsStorage.filterEntities(entity);
return entityMetadata?.standardId === objectMetadata.standardId;
},
);
if (workspaceEntity) {
const entityMetadata =
metadataArgsStorage.filterEntities(workspaceEntity);
if (
isGatedAndNotEnabled(
entityMetadata?.gate,
workspaceFeatureFlagsMap,
'graphql',
)
) {
continue;
}
}
// Generate query resolvers // Generate query resolvers
for (const methodName of workspaceResolverBuilderMethods.queries) { for (const methodName of workspaceResolverBuilderMethods.queries) {
const resolverName = getResolverName(objectMetadata, methodName); const resolverName = getResolverName(objectMetadata, methodName);

View File

@ -9,14 +9,22 @@ import { ScalarsExplorerService } from 'src/engine/api/graphql/services/scalars-
import { workspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/factories/factories'; import { workspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/factories/factories';
import { WorkspaceResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory'; import { WorkspaceResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory';
import { WorkspaceGraphQLSchemaFactory } from 'src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory'; import { WorkspaceGraphQLSchemaFactory } from 'src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { import {
WorkspaceMetadataCacheException, WorkspaceMetadataCacheException,
WorkspaceMetadataCacheExceptionCode, WorkspaceMetadataCacheExceptionCode,
} from 'src/engine/metadata-modules/workspace-metadata-cache/exceptions/workspace-metadata-cache.exception'; } from 'src/engine/metadata-modules/workspace-metadata-cache/exceptions/workspace-metadata-cache.exception';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util';
@Injectable() @Injectable()
export class WorkspaceSchemaFactory { export class WorkspaceSchemaFactory {
@ -27,6 +35,7 @@ export class WorkspaceSchemaFactory {
private readonly workspaceResolverFactory: WorkspaceResolverFactory, private readonly workspaceResolverFactory: WorkspaceResolverFactory,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService, private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
private readonly featureFlagService: FeatureFlagService,
) {} ) {}
async createGraphQLSchema(authContext: AuthContext): Promise<GraphQLSchema> { async createGraphQLSchema(authContext: AuthContext): Promise<GraphQLSchema> {
@ -57,13 +66,49 @@ export class WorkspaceSchemaFactory {
); );
} }
const workspaceId = authContext.workspace.id;
if (!workspaceId) {
throw new AuthException(
'Unauthenticated',
AuthExceptionCode.UNAUTHENTICATED,
);
}
const workspaceFeatureFlagsMap =
await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspaceId);
const objectMetadataCollection = Object.values(objectMetadataMaps.byId) const objectMetadataCollection = Object.values(objectMetadataMaps.byId)
.filter(isDefined) .filter(isDefined)
.map((objectMetadataItem) => ({ .map((objectMetadataItem) => ({
...objectMetadataItem, ...objectMetadataItem,
fields: Object.values(objectMetadataItem.fieldsById), fields: Object.values(objectMetadataItem.fieldsById),
indexes: objectMetadataItem.indexMetadatas, indexes: objectMetadataItem.indexMetadatas,
})); }))
.filter((objectMetadata) => {
// Find the corresponding workspace entity for this object metadata
const workspaceEntity = standardObjectMetadataDefinitions.find(
(entity) => {
const entityMetadata = metadataArgsStorage.filterEntities(entity);
return entityMetadata?.standardId === objectMetadata.standardId;
},
);
if (!workspaceEntity) {
return true; // Include non-workspace entities (custom objects, etc.)
}
const entityMetadata =
metadataArgsStorage.filterEntities(workspaceEntity);
// Filter out entities that are GraphQL-gated and not enabled
return !isGatedAndNotEnabled(
entityMetadata?.gate,
workspaceFeatureFlagsMap,
'graphql',
);
});
// Get typeDefs from cache // Get typeDefs from cache
let typeDefs = await this.workspaceCacheStorageService.getGraphQLTypeDefs( let typeDefs = await this.workspaceCacheStorageService.getGraphQLTypeDefs(

View File

@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from '@nestjs/typeorm';
import { CreatedByFromAuthContextService } from 'src/engine/core-modules/actor/services/created-by-from-auth-context.service'; import { CreatedByFromAuthContextService } from 'src/engine/core-modules/actor/services/created-by-from-auth-context.service';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@ -12,12 +13,11 @@ import {
import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type'; import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
type TestingAuthContext = Omit<AuthContext, 'workspace' | 'apiKey' | 'user'> & { type TestingAuthContext = Omit<AuthContext, 'workspace' | 'apiKey' | 'user'> & {
workspace: Partial<Workspace>; workspace: Partial<Workspace>;
apiKey?: Partial<ApiKeyWorkspaceEntity>; apiKey?: Partial<ApiKey>;
user?: Partial<User>; user?: Partial<User>;
}; };

View File

@ -1,11 +1,11 @@
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { import {
ActorMetadata, ActorMetadata,
FieldActorSource, FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
type BuildCreatedByFromApiKeyArgs = { type BuildCreatedByFromApiKeyArgs = {
apiKey: ApiKeyWorkspaceEntity; apiKey: ApiKey;
}; };
export const buildCreatedByFromApiKey = ({ export const buildCreatedByFromApiKey = ({
apiKey, apiKey,

View File

@ -0,0 +1,57 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Index('IDX_API_KEY_WORKSPACE_ID', ['workspaceId'])
@Entity({ name: 'apiKey', schema: 'core' })
@ObjectType('ApiKey')
export class ApiKey {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column()
name: string;
@Field(() => Date)
@Column({ type: 'timestamptz' })
expiresAt: Date;
@Field(() => Date, { nullable: true })
@Column({ type: 'timestamptz', nullable: true })
revokedAt?: Date | null;
@Field()
@Column('uuid')
workspaceId: string;
@Field(() => Date)
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Field(() => Date)
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Field(() => Workspace)
@ManyToOne(() => Workspace, (workspace) => workspace.apiKeys, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
}

View File

@ -0,0 +1,18 @@
import { CustomException } from 'src/utils/custom-exception';
export class ApiKeyException extends CustomException {
declare code: ApiKeyExceptionCode;
constructor(
message: string,
code: ApiKeyExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export enum ApiKeyExceptionCode {
API_KEY_NOT_FOUND = 'API_KEY_NOT_FOUND',
API_KEY_REVOKED = 'API_KEY_REVOKED',
API_KEY_EXPIRED = 'API_KEY_EXPIRED',
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApiKeyResolver } from 'src/engine/core-modules/api-key/api-key.resolver';
import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
@Module({
imports: [TypeOrmModule.forFeature([ApiKey], 'core'), JwtModule],
providers: [ApiKeyService, ApiKeyResolver],
exports: [ApiKeyService, TypeOrmModule],
})
export class ApiKeyModule {}

View File

@ -0,0 +1,82 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { CreateApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/create-api-key.dto';
import { GetApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/get-api-key.dto';
import { RevokeApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/revoke-api-key.dto';
import { UpdateApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/update-api-key.dto';
import { apiKeyGraphqlApiExceptionHandler } from 'src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ApiKey } from './api-key.entity';
import { ApiKeyService } from './api-key.service';
@Resolver(() => ApiKey)
@UseGuards(WorkspaceAuthGuard)
export class ApiKeyResolver {
constructor(private readonly apiKeyService: ApiKeyService) {}
@Query(() => [ApiKey])
async apiKeys(@AuthWorkspace() workspace: Workspace): Promise<ApiKey[]> {
return this.apiKeyService.findActiveByWorkspaceId(workspace.id);
}
@Query(() => ApiKey, { nullable: true })
async apiKey(
@Args('input') input: GetApiKeyDTO,
@AuthWorkspace() workspace: Workspace,
): Promise<ApiKey | null> {
try {
const apiKey = await this.apiKeyService.findById(input.id, workspace.id);
if (!apiKey) {
return null;
}
return apiKey;
} catch (error) {
apiKeyGraphqlApiExceptionHandler(error);
throw error;
}
}
@Mutation(() => ApiKey)
async createApiKey(
@AuthWorkspace() workspace: Workspace,
@Args('input') input: CreateApiKeyDTO,
): Promise<ApiKey> {
return this.apiKeyService.create({
name: input.name,
expiresAt: new Date(input.expiresAt),
revokedAt: input.revokedAt ? new Date(input.revokedAt) : undefined,
workspaceId: workspace.id,
});
}
@Mutation(() => ApiKey, { nullable: true })
async updateApiKey(
@AuthWorkspace() workspace: Workspace,
@Args('input') input: UpdateApiKeyDTO,
): Promise<ApiKey | null> {
const updateData: Partial<ApiKey> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.expiresAt !== undefined)
updateData.expiresAt = new Date(input.expiresAt);
if (input.revokedAt !== undefined) {
updateData.revokedAt = input.revokedAt ? new Date(input.revokedAt) : null;
}
return this.apiKeyService.update(input.id, workspace.id, updateData);
}
@Mutation(() => ApiKey, { nullable: true })
async revokeApiKey(
@AuthWorkspace() workspace: Workspace,
@Args('input') input: RevokeApiKeyDTO,
): Promise<ApiKey | null> {
return this.apiKeyService.revoke(input.id, workspace.id);
}
}

View File

@ -0,0 +1,383 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { IsNull } from 'typeorm';
import {
ApiKeyException,
ApiKeyExceptionCode,
} from 'src/engine/core-modules/api-key/api-key.exception';
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 { ApiKey } from './api-key.entity';
import { ApiKeyService } from './api-key.service';
describe('ApiKeyService', () => {
let service: ApiKeyService;
let mockApiKeyRepository: any;
let mockJwtWrapperService: any;
const mockWorkspaceId = 'workspace-123';
const mockApiKeyId = 'api-key-456';
const mockApiKey: ApiKey = {
id: mockApiKeyId,
name: 'Test API Key',
expiresAt: new Date('2025-12-31'),
revokedAt: undefined,
workspaceId: mockWorkspaceId,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
workspace: {} as any,
};
const mockRevokedApiKey: ApiKey = {
...mockApiKey,
id: 'revoked-api-key',
revokedAt: new Date('2024-06-01'),
};
const mockExpiredApiKey: ApiKey = {
...mockApiKey,
id: 'expired-api-key',
expiresAt: new Date('2024-01-01'),
};
beforeEach(async () => {
mockApiKeyRepository = {
create: jest.fn(),
save: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
};
mockJwtWrapperService = {
generateAppSecret: jest.fn(),
sign: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ApiKeyService,
{
provide: getRepositoryToken(ApiKey, 'core'),
useValue: mockApiKeyRepository,
},
{
provide: JwtWrapperService,
useValue: mockJwtWrapperService,
},
],
}).compile();
service = module.get<ApiKeyService>(ApiKeyService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should create and save an API key', async () => {
const apiKeyData = {
name: 'New API Key',
expiresAt: new Date('2025-12-31'),
workspaceId: mockWorkspaceId,
};
mockApiKeyRepository.create.mockReturnValue(mockApiKey);
mockApiKeyRepository.save.mockResolvedValue(mockApiKey);
const result = await service.create(apiKeyData);
expect(mockApiKeyRepository.create).toHaveBeenCalledWith(apiKeyData);
expect(mockApiKeyRepository.save).toHaveBeenCalledWith(mockApiKey);
expect(result).toEqual(mockApiKey);
});
});
describe('findById', () => {
it('should find an API key by ID and workspace ID', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
const result = await service.findById(mockApiKeyId, mockWorkspaceId);
expect(mockApiKeyRepository.findOne).toHaveBeenCalledWith({
where: {
id: mockApiKeyId,
workspaceId: mockWorkspaceId,
},
});
expect(result).toEqual(mockApiKey);
});
it('should return null if API key not found', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(null);
const result = await service.findById('non-existent', mockWorkspaceId);
expect(result).toBeNull();
});
});
describe('findByWorkspaceId', () => {
it('should find all API keys for a workspace', async () => {
const mockApiKeys = [mockApiKey, { ...mockApiKey, id: 'another-key' }];
mockApiKeyRepository.find.mockResolvedValue(mockApiKeys);
const result = await service.findByWorkspaceId(mockWorkspaceId);
expect(mockApiKeyRepository.find).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
},
});
expect(result).toEqual(mockApiKeys);
});
});
describe('findActiveByWorkspaceId', () => {
it('should find only active (non-revoked) API keys', async () => {
const activeApiKeys = [mockApiKey];
mockApiKeyRepository.find.mockResolvedValue(activeApiKeys);
const result = await service.findActiveByWorkspaceId(mockWorkspaceId);
expect(mockApiKeyRepository.find).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
revokedAt: IsNull(),
},
});
expect(result).toEqual(activeApiKeys);
});
});
describe('update', () => {
it('should update an existing API key', async () => {
const updateData = { name: 'Updated API Key' };
const updatedApiKey = { ...mockApiKey, ...updateData };
mockApiKeyRepository.findOne
.mockResolvedValueOnce(mockApiKey)
.mockResolvedValueOnce(updatedApiKey);
mockApiKeyRepository.update.mockResolvedValue({ affected: 1 });
const result = await service.update(
mockApiKeyId,
mockWorkspaceId,
updateData,
);
expect(mockApiKeyRepository.update).toHaveBeenCalledWith(
mockApiKeyId,
updateData,
);
expect(result).toEqual(updatedApiKey);
});
it('should return null if API key to update does not exist', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(null);
const result = await service.update('non-existent', mockWorkspaceId, {
name: 'Updated',
});
expect(mockApiKeyRepository.update).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe('revoke', () => {
it('should revoke an API key by setting revokedAt', async () => {
const revokedApiKey = { ...mockApiKey, revokedAt: new Date() };
mockApiKeyRepository.findOne
.mockResolvedValueOnce(mockApiKey)
.mockResolvedValueOnce(revokedApiKey);
mockApiKeyRepository.update.mockResolvedValue({ affected: 1 });
const result = await service.revoke(mockApiKeyId, mockWorkspaceId);
expect(mockApiKeyRepository.update).toHaveBeenCalledWith(
mockApiKeyId,
expect.objectContaining({
revokedAt: expect.any(Date),
}),
);
expect(result).toEqual(revokedApiKey);
});
});
describe('validateApiKey', () => {
it('should validate an active API key', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
const result = await service.validateApiKey(
mockApiKeyId,
mockWorkspaceId,
);
expect(result).toEqual(mockApiKey);
});
it('should throw ApiKeyException if API key does not exist', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(null);
await expect(
service.validateApiKey('non-existent', mockWorkspaceId),
).rejects.toThrow(ApiKeyException);
await expect(
service.validateApiKey('non-existent', mockWorkspaceId),
).rejects.toMatchObject({
code: ApiKeyExceptionCode.API_KEY_NOT_FOUND,
});
});
it('should throw ApiKeyException if API key is revoked', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockRevokedApiKey);
await expect(
service.validateApiKey(mockRevokedApiKey.id, mockWorkspaceId),
).rejects.toThrow(ApiKeyException);
await expect(
service.validateApiKey(mockRevokedApiKey.id, mockWorkspaceId),
).rejects.toMatchObject({
code: ApiKeyExceptionCode.API_KEY_REVOKED,
});
});
it('should throw ApiKeyException if API key is expired', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockExpiredApiKey);
await expect(
service.validateApiKey(mockExpiredApiKey.id, mockWorkspaceId),
).rejects.toThrow(ApiKeyException);
await expect(
service.validateApiKey(mockExpiredApiKey.id, mockWorkspaceId),
).rejects.toMatchObject({
code: ApiKeyExceptionCode.API_KEY_EXPIRED,
});
});
});
describe('generateApiKeyToken', () => {
const mockSecret = 'mock-secret';
const mockToken = 'mock-jwt-token';
beforeEach(() => {
mockJwtWrapperService.generateAppSecret.mockReturnValue(mockSecret);
mockJwtWrapperService.sign.mockReturnValue(mockToken);
});
it('should generate a JWT token for a valid API key', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
const expiresAt = new Date('2025-12-31');
const result = await service.generateApiKeyToken(
mockWorkspaceId,
mockApiKeyId,
expiresAt,
);
expect(mockJwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
JwtTokenTypeEnum.ACCESS,
mockWorkspaceId,
);
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
{
sub: mockWorkspaceId,
type: JwtTokenTypeEnum.API_KEY,
workspaceId: mockWorkspaceId,
},
{
secret: mockSecret,
expiresIn: expect.any(Number),
jwtid: mockApiKeyId,
},
);
expect(result).toEqual({ token: mockToken });
});
it('should return undefined if no API key ID provided', async () => {
const result = await service.generateApiKeyToken(mockWorkspaceId);
expect(result).toBeUndefined();
expect(mockJwtWrapperService.generateAppSecret).not.toHaveBeenCalled();
});
it('should use default expiration if no expiresAt provided', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
await service.generateApiKeyToken(mockWorkspaceId, mockApiKeyId);
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
expiresIn: '100y',
}),
);
});
});
describe('utility methods', () => {
describe('isExpired', () => {
it('should return true for expired API key', () => {
const result = service.isExpired(mockExpiredApiKey);
expect(result).toBe(true);
});
it('should return false for non-expired API key', () => {
const result = service.isExpired(mockApiKey);
expect(result).toBe(false);
});
});
describe('isRevoked', () => {
it('should return true for revoked API key', () => {
const result = service.isRevoked(mockRevokedApiKey);
expect(result).toBe(true);
});
it('should return false for non-revoked API key', () => {
const result = service.isRevoked(mockApiKey);
expect(result).toBe(false);
});
});
describe('isActive', () => {
it('should return true for active API key', () => {
const result = service.isActive(mockApiKey);
expect(result).toBe(true);
});
it('should return false for revoked API key', () => {
const result = service.isActive(mockRevokedApiKey);
expect(result).toBe(false);
});
it('should return false for expired API key', () => {
const result = service.isActive(mockExpiredApiKey);
expect(result).toBe(false);
});
});
});
});

View File

@ -0,0 +1,165 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import {
ApiKeyException,
ApiKeyExceptionCode,
} from 'src/engine/core-modules/api-key/api-key.exception';
import { ApiKeyToken } from 'src/engine/core-modules/auth/dto/token.entity';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@Injectable()
export class ApiKeyService {
constructor(
@InjectRepository(ApiKey, 'core')
private readonly apiKeyRepository: Repository<ApiKey>,
private readonly jwtWrapperService: JwtWrapperService,
) {}
async create(apiKeyData: Partial<ApiKey>): Promise<ApiKey> {
const apiKey = this.apiKeyRepository.create(apiKeyData);
return await this.apiKeyRepository.save(apiKey);
}
async findById(id: string, workspaceId: string): Promise<ApiKey | null> {
return await this.apiKeyRepository.findOne({
where: {
id,
workspaceId,
},
});
}
async findByWorkspaceId(workspaceId: string): Promise<ApiKey[]> {
return await this.apiKeyRepository.find({
where: {
workspaceId,
},
});
}
async findActiveByWorkspaceId(workspaceId: string): Promise<ApiKey[]> {
return await this.apiKeyRepository.find({
where: {
workspaceId,
revokedAt: IsNull(),
},
});
}
async update(
id: string,
workspaceId: string,
updateData: Partial<ApiKey>,
): Promise<ApiKey | null> {
const apiKey = await this.findById(id, workspaceId);
if (!apiKey) {
return null;
}
await this.apiKeyRepository.update(id, updateData);
return this.findById(id, workspaceId);
}
async revoke(id: string, workspaceId: string): Promise<ApiKey | null> {
return await this.update(id, workspaceId, {
revokedAt: new Date(),
});
}
async validateApiKey(id: string, workspaceId: string): Promise<ApiKey> {
const apiKey = await this.findById(id, workspaceId);
if (!apiKey) {
throw new ApiKeyException(
`API Key with id ${id} not found`,
ApiKeyExceptionCode.API_KEY_NOT_FOUND,
);
}
if (apiKey.revokedAt) {
throw new ApiKeyException(
'This API Key is revoked',
ApiKeyExceptionCode.API_KEY_REVOKED,
{
userFriendlyMessage:
'This API Key has been revoked and can no longer be used.',
},
);
}
if (new Date() > apiKey.expiresAt) {
throw new ApiKeyException(
'This API Key has expired',
ApiKeyExceptionCode.API_KEY_EXPIRED,
{
userFriendlyMessage:
'This API Key has expired. Please create a new one.',
},
);
}
return apiKey;
}
async generateApiKeyToken(
workspaceId: string,
apiKeyId?: string,
expiresAt?: Date | string,
): Promise<Pick<ApiKeyToken, 'token'> | undefined> {
if (!apiKeyId) {
return;
}
await this.validateApiKey(apiKeyId, workspaceId);
const secret = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.ACCESS,
workspaceId,
);
let expiresIn: string | number;
if (expiresAt) {
expiresIn = Math.floor(
(new Date(expiresAt).getTime() - new Date().getTime()) / 1000,
);
} else {
expiresIn = '100y';
}
const token = this.jwtWrapperService.sign(
{
sub: workspaceId,
type: JwtTokenTypeEnum.API_KEY,
workspaceId,
},
{
secret,
expiresIn,
jwtid: apiKeyId,
},
);
return { token };
}
isExpired(apiKey: ApiKey): boolean {
return new Date() > apiKey.expiresAt;
}
isRevoked(apiKey: ApiKey): boolean {
return !!apiKey.revokedAt;
}
isActive(apiKey: ApiKey): boolean {
return !this.isRevoked(apiKey) && !this.isExpired(apiKey);
}
}

View File

@ -0,0 +1,25 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
@InputType()
export class CreateApiKeyDTO {
@Field()
@IsNotEmpty()
@IsString()
name: string;
@Field()
@IsDateString()
expiresAt: string;
@Field({ nullable: true })
@IsOptional()
@IsDateString()
revokedAt?: string;
}

View File

@ -0,0 +1,11 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class GetApiKeyDTO {
@Field()
@IsNotEmpty()
@IsString()
id: string;
}

View File

@ -0,0 +1,11 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class RevokeApiKeyDTO {
@Field()
@IsNotEmpty()
@IsString()
id: string;
}

View File

@ -0,0 +1,31 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
@InputType()
export class UpdateApiKeyDTO {
@Field()
@IsNotEmpty()
@IsString()
id: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
name?: string;
@Field({ nullable: true })
@IsDateString()
@IsOptional()
expiresAt?: string;
@Field({ nullable: true })
@IsOptional()
@IsDateString()
revokedAt?: string;
}

View File

@ -0,0 +1,33 @@
import {
ApiKeyException,
ApiKeyExceptionCode,
} from 'src/engine/core-modules/api-key/api-key.exception';
import {
ForbiddenError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
export const apiKeyGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof ApiKeyException) {
switch (error.code) {
case ApiKeyExceptionCode.API_KEY_NOT_FOUND:
throw new NotFoundError(error.message);
case ApiKeyExceptionCode.API_KEY_REVOKED:
throw new ForbiddenError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
case ApiKeyExceptionCode.API_KEY_EXPIRED:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
default: {
const _exhaustiveCheck: never = error.code;
throw error;
}
}
}
throw error;
};

View File

@ -4,6 +4,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service'; import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller'; import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller';
@ -79,6 +80,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
Workspace, Workspace,
User, User,
AppToken, AppToken,
ApiKey,
FeatureFlag, FeatureFlag,
WorkspaceSSOIdentityProvider, WorkspaceSSOIdentityProvider,
KeyValuePair, KeyValuePair,

View File

@ -3,235 +3,263 @@ import {
AuthExceptionCode, AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception'; } from 'src/engine/core-modules/auth/auth.exception';
import { JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type'; import { JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { JwtAuthStrategy } from './jwt.auth.strategy'; import { JwtAuthStrategy } from './jwt.auth.strategy';
describe('JwtAuthStrategy', () => { describe('JwtAuthStrategy', () => {
let strategy: JwtAuthStrategy; let strategy: JwtAuthStrategy;
let workspaceRepository: any; let workspaceRepository: any;
let userWorkspaceRepository: any; let userWorkspaceRepository: any;
let userRepository: any; let userRepository: any;
let twentyORMGlobalManager: any; let apiKeyRepository: any;
let jwtWrapperService: any;
const jwt = { const jwt = {
sub: 'sub-default', sub: 'sub-default',
jti: 'jti-default', jti: 'jti-default',
}; };
workspaceRepository = { beforeEach(() => {
findOneBy: jest.fn(async () => new Workspace()),
};
userRepository = {
findOne: jest.fn(async () => null),
};
userWorkspaceRepository = {
findOne: jest.fn(async () => new UserWorkspace()),
};
const jwtWrapperService: any = {
extractJwtFromRequest: jest.fn(() => () => 'token'),
};
twentyORMGlobalManager = {
getRepositoryForWorkspace: jest.fn(async () => ({
findOne: jest.fn(async () => ({ id: 'api-key-id', revokedAt: null })),
})),
};
// first we test the API_KEY case
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
workspaceRepository = { workspaceRepository = {
findOneBy: jest.fn(async () => null), findOneBy: jest.fn(),
};
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
{} as any,
userWorkspaceRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
),
);
});
it('should throw AuthExceptionCode if type is API_KEY not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
};
twentyORMGlobalManager = {
getRepositoryForWorkspace: jest.fn(async () => ({
findOne: jest.fn(async () => null),
})),
};
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
{} as any,
userWorkspaceRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'This API Key is revoked',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
});
it('should be truthy if type is API_KEY and API_KEY is not revoked', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
};
twentyORMGlobalManager = {
getRepositoryForWorkspace: jest.fn(async () => ({
findOne: jest.fn(async () => ({ id: 'api-key-id', revokedAt: null })),
})),
};
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
{} as any,
userWorkspaceRepository,
);
const result = await strategy.validate(payload as JwtPayload);
expect(result).toBeTruthy();
expect(result.apiKey?.id).toBe('api-key-id');
});
// second we test the ACCESS cases
it('should throw AuthExceptionCode if type is ACCESS, no jti, and user not found', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
};
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
}; };
userRepository = { userRepository = {
findOne: jest.fn(async () => null), findOne: jest.fn(),
};
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
userRepository,
userWorkspaceRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspace not found', expect.any(String)),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
}
});
it('should throw AuthExceptionCode if type is ACCESS, no jti, and userWorkspace not found', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
};
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
};
userRepository = {
findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })),
}; };
userWorkspaceRepository = { userWorkspaceRepository = {
findOne: jest.fn(async () => null), findOne: jest.fn(),
}; };
strategy = new JwtAuthStrategy( apiKeyRepository = {
jwtWrapperService, findOne: jest.fn(),
twentyORMGlobalManager, };
workspaceRepository,
userRepository,
userWorkspaceRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( jwtWrapperService = {
new AuthException('UserWorkspace not found', expect.any(String)), extractJwtFromRequest: jest.fn(() => () => 'token'),
); };
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
}
}); });
it('should not throw if type is ACCESS, no jti, and user and userWorkspace exist', async () => { afterEach(() => {
const payload = { jest.clearAllMocks();
sub: 'sub-default', });
type: 'ACCESS',
userWorkspaceId: 'userWorkspaceId',
};
workspaceRepository = { describe('API_KEY validation', () => {
findOneBy: jest.fn(async () => new Workspace()), it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
}; const payload = {
...jwt,
type: 'API_KEY',
};
userRepository = { workspaceRepository.findOneBy.mockResolvedValue(null);
findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })),
};
userWorkspaceRepository = { strategy = new JwtAuthStrategy(
findOne: jest.fn(async () => ({ jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
),
);
});
it('should throw AuthExceptionCode if type is API_KEY not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
const mockWorkspace = new Workspace();
mockWorkspace.id = 'workspace-id';
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
apiKeyRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'This API Key is revoked',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
});
it('should throw AuthExceptionCode if API_KEY is revoked', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
const mockWorkspace = new Workspace();
mockWorkspace.id = 'workspace-id';
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
apiKeyRepository.findOne.mockResolvedValue({
id: 'api-key-id',
revokedAt: new Date(),
});
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'This API Key is revoked',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
});
it('should be truthy if type is API_KEY and API_KEY is not revoked', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
const mockWorkspace = new Workspace();
mockWorkspace.id = 'workspace-id';
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
apiKeyRepository.findOne.mockResolvedValue({
id: 'api-key-id',
revokedAt: null,
});
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
const result = await strategy.validate(payload as JwtPayload);
expect(result).toBeTruthy();
expect(result.apiKey?.id).toBe('api-key-id');
expect(apiKeyRepository.findOne).toHaveBeenCalledWith({
where: {
id: payload.jti,
workspaceId: mockWorkspace.id,
},
});
});
});
describe('ACCESS token validation', () => {
it('should throw AuthExceptionCode if type is ACCESS, no jti, and user not found', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
userWorkspaceId: 'userWorkspaceId',
};
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
userRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspace not found', expect.any(String)),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
}
});
it('should throw AuthExceptionCode if type is ACCESS, no jti, and userWorkspace not found', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
userWorkspaceId: 'userWorkspaceId',
};
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
userRepository.findOne.mockResolvedValue({ lastName: 'lastNameDefault' });
userWorkspaceRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspace not found', expect.any(String)),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
}
});
it('should not throw if type is ACCESS, no jti, and user and userWorkspace exist', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
userWorkspaceId: 'userWorkspaceId',
};
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
userRepository.findOne.mockResolvedValue({ lastName: 'lastNameDefault' });
userWorkspaceRepository.findOne.mockResolvedValue({
id: 'userWorkspaceId', id: 'userWorkspaceId',
})), });
};
strategy = new JwtAuthStrategy( strategy = new JwtAuthStrategy(
jwtWrapperService, jwtWrapperService,
twentyORMGlobalManager, workspaceRepository,
workspaceRepository, userRepository,
userRepository, userWorkspaceRepository,
userWorkspaceRepository, apiKeyRepository,
); );
const user = await strategy.validate(payload as JwtPayload); const user = await strategy.validate(payload as JwtPayload);
expect(user.user?.lastName).toBe('lastNameDefault'); expect(user.user?.lastName).toBe('lastNameDefault');
expect(user.userWorkspaceId).toBe('userWorkspaceId'); expect(user.userWorkspaceId).toBe('userWorkspaceId');
});
}); });
}); });

View File

@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Strategy } from 'passport-jwt'; import { Strategy } from 'passport-jwt';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { import {
AuthException, AuthException,
AuthExceptionCode, AuthExceptionCode,
@ -24,20 +25,18 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
@Injectable() @Injectable()
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor( constructor(
private readonly jwtWrapperService: JwtWrapperService, private readonly jwtWrapperService: JwtWrapperService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(User, 'core') @InjectRepository(User, 'core')
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
@InjectRepository(UserWorkspace, 'core') @InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>, private readonly userWorkspaceRepository: Repository<UserWorkspace>,
@InjectRepository(ApiKey, 'core')
private readonly apiKeyRepository: Repository<ApiKey>,
) { ) {
const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest(); const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
// @ts-expect-error legacy noImplicitAny // @ts-expect-error legacy noImplicitAny
@ -87,15 +86,10 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
), ),
); );
const apiKeyRepository = const apiKey = await this.apiKeyRepository.findOne({
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ApiKeyWorkspaceEntity>(
workspace.id,
'apiKey',
);
const apiKey = await apiKeyRepository.findOne({
where: { where: {
id: payload.jti, id: payload.jti,
workspaceId: workspace.id,
}, },
}); });

View File

@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
@ -21,7 +22,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
imports: [ imports: [
JwtModule, JwtModule,
TypeOrmModule.forFeature( TypeOrmModule.forFeature(
[User, AppToken, Workspace, UserWorkspace], [User, AppToken, Workspace, UserWorkspace, ApiKey],
'core', 'core',
), ),
TypeORMModule, TypeORMModule,

View File

@ -1,12 +1,12 @@
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
export type AuthContext = { export type AuthContext = {
user?: User | null | undefined; user?: User | null | undefined;
apiKey?: ApiKeyWorkspaceEntity | null | undefined; apiKey?: ApiKey | null | undefined;
workspaceMemberId?: string; workspaceMemberId?: string;
workspace?: Workspace; workspace?: Workspace;
userWorkspaceId?: string; userWorkspaceId?: string;

View File

@ -7,6 +7,7 @@ import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module'; import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
import { AiModule } from 'src/engine/core-modules/ai/ai.module'; import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { aiModuleFactory } from 'src/engine/core-modules/ai/ai.module-factory'; import { aiModuleFactory } from 'src/engine/core-modules/ai/ai.module-factory';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module'; import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module'; import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
@ -42,6 +43,7 @@ import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.mod
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserModule } from 'src/engine/core-modules/user/user.module'; import { UserModule } from 'src/engine/core-modules/user/user.module';
import { WebhookModule } from 'src/engine/core-modules/webhook/webhook.module';
import { WorkflowApiModule } from 'src/engine/core-modules/workflow/workflow-api.module'; import { WorkflowApiModule } from 'src/engine/core-modules/workflow/workflow-api.module';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
@ -116,6 +118,8 @@ import { FileModule } from './file/file.module';
inject: [TwentyConfigService, FileStorageService], inject: [TwentyConfigService, FileStorageService],
}), }),
SearchModule, SearchModule,
ApiKeyModule,
WebhookModule,
], ],
exports: [ exports: [
AuditModule, AuditModule,

View File

@ -8,5 +8,7 @@ export enum FeatureFlagKey {
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED', IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED', IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED',
IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED', IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED',
} }

View File

@ -0,0 +1,20 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsUrl } from 'class-validator';
@InputType()
export class CreateWebhookDTO {
@Field()
@IsNotEmpty()
@IsUrl()
targetUrl: string;
@Field(() => [String])
operations: string[];
@Field({ nullable: true })
description?: string;
@Field({ nullable: true })
secret?: string;
}

View File

@ -0,0 +1,11 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class DeleteWebhookDTO {
@Field()
@IsNotEmpty()
@IsString()
id: string;
}

View File

@ -0,0 +1,11 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class GetWebhookDTO {
@Field()
@IsNotEmpty()
@IsString()
id: string;
}

View File

@ -0,0 +1,23 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class UpdateWebhookDTO {
@Field()
@IsNotEmpty()
@IsString()
id: string;
@Field({ nullable: true })
targetUrl?: string;
@Field(() => [String], { nullable: true })
operations?: string[];
@Field({ nullable: true })
description?: string;
@Field({ nullable: true })
secret?: string;
}

View File

@ -0,0 +1,28 @@
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
WebhookException,
WebhookExceptionCode,
} from 'src/engine/core-modules/webhook/webhook.exception';
export const webhookGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof WebhookException) {
switch (error.code) {
case WebhookExceptionCode.WEBHOOK_NOT_FOUND:
throw new NotFoundError(error.message);
case WebhookExceptionCode.INVALID_TARGET_URL:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
default: {
const _exhaustiveCheck: never = error.code;
throw error;
}
}
}
throw error;
};

View File

@ -0,0 +1,66 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Index('IDX_WEBHOOK_WORKSPACE_ID', ['workspaceId'])
@Entity({ name: 'webhook', schema: 'core' })
@ObjectType('Webhook')
export class Webhook {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column()
targetUrl: string;
@Field(() => [String])
@Column('text', { array: true, default: ['*.*'] })
operations: string[];
@Field({ nullable: true })
@Column({ nullable: true })
description?: string;
@Field()
@Column()
secret: string;
@Field()
@Column('uuid')
workspaceId: string;
@Field()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Field()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Field({ nullable: true })
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
@Field(() => Workspace)
@ManyToOne(() => Workspace, (workspace) => workspace.webhooks, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
}

View File

@ -0,0 +1,17 @@
import { CustomException } from 'src/utils/custom-exception';
export class WebhookException extends CustomException {
declare code: WebhookExceptionCode;
constructor(
message: string,
code: WebhookExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export enum WebhookExceptionCode {
WEBHOOK_NOT_FOUND = 'WEBHOOK_NOT_FOUND',
INVALID_TARGET_URL = 'INVALID_TARGET_URL',
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Webhook } from './webhook.entity';
import { WebhookResolver } from './webhook.resolver';
import { WebhookService } from './webhook.service';
@Module({
imports: [TypeOrmModule.forFeature([Webhook], 'core')],
providers: [WebhookService, WebhookResolver],
exports: [WebhookService, TypeOrmModule],
})
export class WebhookModule {}

View File

@ -0,0 +1,88 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { CreateWebhookDTO } from 'src/engine/core-modules/webhook/dtos/create-webhook.dto';
import { DeleteWebhookDTO } from 'src/engine/core-modules/webhook/dtos/delete-webhook.dto';
import { GetWebhookDTO } from 'src/engine/core-modules/webhook/dtos/get-webhook.dto';
import { UpdateWebhookDTO } from 'src/engine/core-modules/webhook/dtos/update-webhook.dto';
import { webhookGraphqlApiExceptionHandler } from 'src/engine/core-modules/webhook/utils/webhook-graphql-api-exception-handler.util';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { Webhook } from './webhook.entity';
import { WebhookService } from './webhook.service';
@Resolver(() => Webhook)
@UseGuards(WorkspaceAuthGuard)
export class WebhookResolver {
constructor(private readonly webhookService: WebhookService) {}
@Query(() => [Webhook])
async webhooks(@AuthWorkspace() workspace: Workspace): Promise<Webhook[]> {
return this.webhookService.findByWorkspaceId(workspace.id);
}
@Query(() => Webhook, { nullable: true })
async webhook(
@Args('input') input: GetWebhookDTO,
@AuthWorkspace() workspace: Workspace,
): Promise<Webhook | null> {
return this.webhookService.findById(input.id, workspace.id);
}
@Mutation(() => Webhook)
async createWebhook(
@AuthWorkspace() workspace: Workspace,
@Args('input') input: CreateWebhookDTO,
): Promise<Webhook> {
try {
return await this.webhookService.create({
targetUrl: input.targetUrl,
operations: input.operations,
description: input.description,
secret: input.secret,
workspaceId: workspace.id,
});
} catch (error) {
webhookGraphqlApiExceptionHandler(error);
throw error; // This line will never be reached but satisfies TypeScript
}
}
@Mutation(() => Webhook, { nullable: true })
async updateWebhook(
@AuthWorkspace() workspace: Workspace,
@Args('input') input: UpdateWebhookDTO,
): Promise<Webhook | null> {
try {
const updateData: Partial<Webhook> = {};
if (input.targetUrl !== undefined) updateData.targetUrl = input.targetUrl;
if (input.operations !== undefined)
updateData.operations = input.operations;
if (input.description !== undefined)
updateData.description = input.description;
if (input.secret !== undefined) updateData.secret = input.secret;
return await this.webhookService.update(
input.id,
workspace.id,
updateData,
);
} catch (error) {
webhookGraphqlApiExceptionHandler(error);
throw error; // This line will never be reached but satisfies TypeScript
}
}
@Mutation(() => Boolean)
async deleteWebhook(
@Args('input') input: DeleteWebhookDTO,
@AuthWorkspace() workspace: Workspace,
): Promise<boolean> {
const result = await this.webhookService.delete(input.id, workspace.id);
return result !== null;
}
}

View File

@ -0,0 +1,420 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ArrayContains, IsNull } from 'typeorm';
import { Webhook } from './webhook.entity';
import { WebhookException, WebhookExceptionCode } from './webhook.exception';
import { WebhookService } from './webhook.service';
describe('WebhookService', () => {
let service: WebhookService;
let mockWebhookRepository: any;
const mockWorkspaceId = 'workspace-123';
const mockWebhookId = 'webhook-456';
const mockWebhook: Webhook = {
id: mockWebhookId,
targetUrl: 'https://example.com/webhook',
secret: 'webhook-secret',
operations: ['create', 'update'],
workspaceId: mockWorkspaceId,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
deletedAt: undefined,
workspace: {} as any,
};
beforeEach(async () => {
mockWebhookRepository = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
WebhookService,
{
provide: getRepositoryToken(Webhook, 'core'),
useValue: mockWebhookRepository,
},
],
}).compile();
service = module.get<WebhookService>(WebhookService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('normalizeTargetUrl', () => {
it('should normalize valid URLs', () => {
const result = (service as any).normalizeTargetUrl(
'https://example.com/webhook',
);
expect(result).toBe('https://example.com/webhook');
});
it('should return original string if invalid URL', () => {
const invalidUrl = 'not-a-url';
const result = (service as any).normalizeTargetUrl(invalidUrl);
expect(result).toBe(invalidUrl);
});
it('should normalize URL with trailing slash', () => {
const result = (service as any).normalizeTargetUrl(
'https://example.com/webhook/',
);
expect(result).toBe('https://example.com/webhook/');
});
});
describe('validateTargetUrl', () => {
it('should validate HTTPS URLs', () => {
const result = (service as any).validateTargetUrl(
'https://example.com/webhook',
);
expect(result).toBe(true);
});
it('should validate HTTP URLs', () => {
const result = (service as any).validateTargetUrl(
'http://example.com/webhook',
);
expect(result).toBe(true);
});
it('should reject invalid URLs', () => {
const result = (service as any).validateTargetUrl('not-a-url');
expect(result).toBe(false);
});
it('should reject non-HTTP protocols', () => {
const result = (service as any).validateTargetUrl(
'ftp://example.com/webhook',
);
expect(result).toBe(false);
});
});
describe('findByWorkspaceId', () => {
it('should find all webhooks for a workspace', async () => {
const mockWebhooks = [
mockWebhook,
{ ...mockWebhook, id: 'another-webhook' },
];
mockWebhookRepository.find.mockResolvedValue(mockWebhooks);
const result = await service.findByWorkspaceId(mockWorkspaceId);
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
deletedAt: IsNull(),
},
});
expect(result).toEqual(mockWebhooks);
});
});
describe('findByOperations', () => {
it('should find webhooks by operations using ArrayContains', async () => {
const operations = ['create', 'update'];
const mockWebhooks = [mockWebhook];
mockWebhookRepository.find.mockResolvedValue(mockWebhooks);
const result = await service.findByOperations(
mockWorkspaceId,
operations,
);
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
where: operations.map((operation) => ({
workspaceId: mockWorkspaceId,
operations: ArrayContains([operation]),
deletedAt: IsNull(),
})),
});
expect(result).toEqual(mockWebhooks);
});
it('should handle single operation', async () => {
const operations = ['create'];
mockWebhookRepository.find.mockResolvedValue([mockWebhook]);
const result = await service.findByOperations(
mockWorkspaceId,
operations,
);
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
where: [
{
workspaceId: mockWorkspaceId,
operations: ArrayContains(['create']),
deletedAt: IsNull(),
},
],
});
expect(result).toEqual([mockWebhook]);
});
});
describe('findById', () => {
it('should find a webhook by ID and workspace ID', async () => {
mockWebhookRepository.findOne.mockResolvedValue(mockWebhook);
const result = await service.findById(mockWebhookId, mockWorkspaceId);
expect(mockWebhookRepository.findOne).toHaveBeenCalledWith({
where: {
id: mockWebhookId,
workspaceId: mockWorkspaceId,
deletedAt: IsNull(),
},
});
expect(result).toEqual(mockWebhook);
});
it('should return null if webhook not found', async () => {
mockWebhookRepository.findOne.mockResolvedValue(null);
const result = await service.findById('non-existent', mockWorkspaceId);
expect(result).toBeNull();
});
});
describe('create', () => {
it('should create and save a webhook with valid target URL', async () => {
const webhookData = {
targetUrl: 'https://example.com/webhook',
secret: 'webhook-secret',
operations: ['create', 'update'],
workspaceId: mockWorkspaceId,
};
mockWebhookRepository.create.mockReturnValue(mockWebhook);
mockWebhookRepository.save.mockResolvedValue(mockWebhook);
const result = await service.create(webhookData);
expect(mockWebhookRepository.create).toHaveBeenCalledWith({
...webhookData,
targetUrl: 'https://example.com/webhook',
secret: 'webhook-secret',
});
expect(mockWebhookRepository.save).toHaveBeenCalledWith(mockWebhook);
expect(result).toEqual(mockWebhook);
});
it('should throw WebhookException for invalid target URL', async () => {
const webhookData = {
targetUrl: 'invalid-url',
operations: ['create'],
workspaceId: mockWorkspaceId,
};
await expect(service.create(webhookData)).rejects.toThrow(
WebhookException,
);
await expect(service.create(webhookData)).rejects.toMatchObject({
code: WebhookExceptionCode.INVALID_TARGET_URL,
});
expect(mockWebhookRepository.create).not.toHaveBeenCalled();
expect(mockWebhookRepository.save).not.toHaveBeenCalled();
});
it('should throw WebhookException for webhook data without target URL', async () => {
const webhookData = {
operations: ['create'],
workspaceId: mockWorkspaceId,
};
await expect(service.create(webhookData)).rejects.toThrow(
WebhookException,
);
await expect(service.create(webhookData)).rejects.toMatchObject({
code: WebhookExceptionCode.INVALID_TARGET_URL,
});
});
});
describe('update', () => {
it('should update an existing webhook', async () => {
const updateData = { targetUrl: 'https://updated.example.com/webhook' };
const updatedWebhook = { ...mockWebhook, ...updateData };
mockWebhookRepository.findOne
.mockResolvedValueOnce(mockWebhook)
.mockResolvedValueOnce(updatedWebhook);
mockWebhookRepository.update.mockResolvedValue({ affected: 1 });
const result = await service.update(
mockWebhookId,
mockWorkspaceId,
updateData,
);
expect(mockWebhookRepository.update).toHaveBeenCalledWith(
mockWebhookId,
updateData,
);
expect(result).toEqual(updatedWebhook);
});
it('should return null if webhook to update does not exist', async () => {
mockWebhookRepository.findOne.mockResolvedValue(null);
const result = await service.update('non-existent', mockWorkspaceId, {
targetUrl: 'https://updated.example.com',
});
expect(mockWebhookRepository.update).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should throw WebhookException for invalid target URL during update', async () => {
const updateData = { targetUrl: 'invalid-url' };
mockWebhookRepository.findOne.mockResolvedValue(mockWebhook);
await expect(
service.update(mockWebhookId, mockWorkspaceId, updateData),
).rejects.toThrow(WebhookException);
await expect(
service.update(mockWebhookId, mockWorkspaceId, updateData),
).rejects.toMatchObject({
code: WebhookExceptionCode.INVALID_TARGET_URL,
});
expect(mockWebhookRepository.update).not.toHaveBeenCalled();
});
it('should update without target URL validation if targetUrl not in updateData', async () => {
const updateData = { operations: ['create', 'update', 'delete'] };
const updatedWebhook = { ...mockWebhook, ...updateData };
mockWebhookRepository.findOne
.mockResolvedValueOnce(mockWebhook)
.mockResolvedValueOnce(updatedWebhook);
mockWebhookRepository.update.mockResolvedValue({ affected: 1 });
const result = await service.update(
mockWebhookId,
mockWorkspaceId,
updateData,
);
expect(mockWebhookRepository.update).toHaveBeenCalledWith(
mockWebhookId,
updateData,
);
expect(result).toEqual(updatedWebhook);
});
});
describe('delete', () => {
it('should soft delete a webhook', async () => {
mockWebhookRepository.findOne.mockResolvedValue(mockWebhook);
mockWebhookRepository.softDelete.mockResolvedValue({ affected: 1 });
const result = await service.delete(mockWebhookId, mockWorkspaceId);
expect(mockWebhookRepository.findOne).toHaveBeenCalledWith({
where: {
id: mockWebhookId,
workspaceId: mockWorkspaceId,
deletedAt: IsNull(),
},
});
expect(mockWebhookRepository.softDelete).toHaveBeenCalledWith(
mockWebhookId,
);
expect(result).toEqual(mockWebhook);
});
it('should return null if webhook to delete does not exist', async () => {
mockWebhookRepository.findOne.mockResolvedValue(null);
const result = await service.delete('non-existent', mockWorkspaceId);
expect(mockWebhookRepository.softDelete).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe('edge cases', () => {
it('should handle URLs with query parameters', async () => {
const webhookData = {
targetUrl: 'https://example.com/webhook?param=value',
operations: ['create'],
workspaceId: mockWorkspaceId,
};
const normalizedWebhook = {
...mockWebhook,
targetUrl: 'https://example.com/webhook?param=value',
};
mockWebhookRepository.create.mockReturnValue(normalizedWebhook);
mockWebhookRepository.save.mockResolvedValue(normalizedWebhook);
const result = await service.create(webhookData);
expect(result.targetUrl).toBe('https://example.com/webhook?param=value');
});
it('should handle URLs with fragments', async () => {
const webhookData = {
targetUrl: 'https://example.com/webhook#section',
operations: ['create'],
workspaceId: mockWorkspaceId,
};
const normalizedWebhook = {
...mockWebhook,
targetUrl: 'https://example.com/webhook#section',
};
mockWebhookRepository.create.mockReturnValue(normalizedWebhook);
mockWebhookRepository.save.mockResolvedValue(normalizedWebhook);
const result = await service.create(webhookData);
expect(result.targetUrl).toBe('https://example.com/webhook#section');
});
it('should handle empty operations array', async () => {
await service.findByOperations(mockWorkspaceId, []);
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
where: [],
});
});
});
});

View File

@ -0,0 +1,134 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { ArrayContains, IsNull, Repository } from 'typeorm';
import { Webhook } from './webhook.entity';
import { WebhookException, WebhookExceptionCode } from './webhook.exception';
@Injectable()
export class WebhookService {
constructor(
@InjectRepository(Webhook, 'core')
private readonly webhookRepository: Repository<Webhook>,
) {}
private normalizeTargetUrl(targetUrl: string): string {
try {
const url = new URL(targetUrl);
return url.toString();
} catch {
return targetUrl;
}
}
private validateTargetUrl(targetUrl: string): boolean {
try {
const url = new URL(targetUrl);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
async findByWorkspaceId(workspaceId: string): Promise<Webhook[]> {
return this.webhookRepository.find({
where: {
workspaceId,
deletedAt: IsNull(),
},
});
}
async findByOperations(
workspaceId: string,
operations: string[],
): Promise<Webhook[]> {
return this.webhookRepository.find({
where: operations.map((operation) => ({
workspaceId,
operations: ArrayContains([operation]),
deletedAt: IsNull(),
})),
});
}
async findById(id: string, workspaceId: string): Promise<Webhook | null> {
const webhook = await this.webhookRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
return webhook || null;
}
async create(webhookData: Partial<Webhook>): Promise<Webhook> {
const normalizedTargetUrl = this.normalizeTargetUrl(
webhookData.targetUrl || '',
);
if (!this.validateTargetUrl(normalizedTargetUrl)) {
throw new WebhookException(
'Invalid target URL provided',
WebhookExceptionCode.INVALID_TARGET_URL,
{ userFriendlyMessage: 'Please provide a valid HTTP or HTTPS URL.' },
);
}
const webhook = this.webhookRepository.create({
...webhookData,
targetUrl: normalizedTargetUrl,
secret: webhookData.secret,
});
return this.webhookRepository.save(webhook);
}
async update(
id: string,
workspaceId: string,
updateData: Partial<Webhook>,
): Promise<Webhook | null> {
const webhook = await this.findById(id, workspaceId);
if (!webhook) {
return null;
}
if (isDefined(updateData.targetUrl)) {
const normalizedTargetUrl = this.normalizeTargetUrl(updateData.targetUrl);
if (!this.validateTargetUrl(normalizedTargetUrl)) {
throw new WebhookException(
'Invalid target URL provided',
WebhookExceptionCode.INVALID_TARGET_URL,
{ userFriendlyMessage: 'Please provide a valid HTTP or HTTPS URL.' },
);
}
updateData.targetUrl = normalizedTargetUrl;
}
await this.webhookRepository.update(id, updateData);
return this.findById(id, workspaceId);
}
async delete(id: string, workspaceId: string): Promise<Webhook | null> {
const webhook = await this.findById(id, workspaceId);
if (!webhook) {
return null;
}
await this.webhookRepository.softDelete(id);
return webhook;
}
}

View File

@ -15,6 +15,7 @@ import {
} from 'typeorm'; } from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
@ -22,6 +23,7 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity'; import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Webhook } from 'src/engine/core-modules/webhook/webhook.entity';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
@ -127,6 +129,12 @@ export class Workspace {
}) })
agents: Relation<AgentEntity[]>; agents: Relation<AgentEntity[]>;
@OneToMany(() => Webhook, (webhook) => webhook.workspace)
webhooks: Relation<Webhook[]>;
@OneToMany(() => ApiKey, (apiKey) => apiKey.workspace)
apiKeys: Relation<ApiKey[]>;
@Field() @Field()
@Column({ default: 1 }) @Column({ default: 1 })
metadataVersion: number; metadataVersion: number;

View File

@ -4,6 +4,8 @@ import { TypedReflect } from 'src/utils/typed-reflect';
export interface WorkspaceGateOptions { export interface WorkspaceGateOptions {
featureFlag: string; featureFlag: string;
excludeFromDatabase?: boolean;
excludeFromGraphQL?: boolean;
} }
export function WorkspaceGate(options: WorkspaceGateOptions) { export function WorkspaceGate(options: WorkspaceGateOptions) {
@ -16,19 +18,25 @@ export function WorkspaceGate(options: WorkspaceGateOptions) {
); );
} }
const gateOptions = {
featureFlag: options.featureFlag,
excludeFromDatabase: options.excludeFromDatabase ?? true,
excludeFromGraphQL: options.excludeFromGraphQL ?? true,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
return (target: any, propertyKey?: string | symbol) => { return (target: any, propertyKey?: string | symbol) => {
if (propertyKey !== undefined) { if (propertyKey !== undefined) {
TypedReflect.defineMetadata( TypedReflect.defineMetadata(
'workspace:gate-metadata-args', 'workspace:gate-metadata-args',
options, gateOptions,
target, target,
propertyKey.toString(), propertyKey.toString(),
); );
} else { } else {
TypedReflect.defineMetadata( TypedReflect.defineMetadata(
'workspace:gate-metadata-args', 'workspace:gate-metadata-args',
options, gateOptions,
target, target,
); );
} }

View File

@ -1,3 +1,5 @@
export interface Gate { export interface Gate {
featureFlag: string; featureFlag: string;
excludeFromDatabase?: boolean;
excludeFromGraphQL?: boolean;
} }

View File

@ -0,0 +1,29 @@
import { DataSource } from 'typeorm';
const tableName = 'apiKey';
export const seedApiKeys = async (
dataSource: DataSource,
schemaName: string,
workspaceId: string,
) => {
await dataSource
.createQueryBuilder()
.insert()
.into(`${schemaName}.${tableName}`, [
'id',
'name',
'expiresAt',
'workspaceId',
])
.orIgnore()
.values([
{
id: '20202020-f401-4d8a-a731-64d007c27bad',
name: 'My api key',
expiresAt: '2025-12-31T23:59:59.000Z',
workspaceId: workspaceId,
},
])
.execute();
};

View File

@ -1,6 +1,7 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { seedBillingSubscriptions } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-subscriptions.util'; import { seedBillingSubscriptions } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-subscriptions.util';
import { seedApiKeys } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util';
import { seedFeatureFlags } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util'; import { seedFeatureFlags } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util';
import { seedUserWorkspaces } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util'; import { seedUserWorkspaces } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
import { seedUsers } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; import { seedUsers } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
@ -32,6 +33,8 @@ export const seedCoreSchema = async ({
await seedUsers(dataSource, schemaName); await seedUsers(dataSource, schemaName);
await seedUserWorkspaces(dataSource, schemaName, workspaceId); await seedUserWorkspaces(dataSource, schemaName, workspaceId);
await seedApiKeys(dataSource, schemaName, workspaceId);
if (shouldSeedFeatureFlags) { if (shouldSeedFeatureFlags) {
await seedFeatureFlags(dataSource, schemaName, workspaceId); await seedFeatureFlags(dataSource, schemaName, workspaceId);
} }

View File

@ -19,7 +19,7 @@ export const API_KEY_DATA_SEEDS: ApiKeyDataSeed[] = [
id: API_KEY_DATA_SEED_IDS.ID_1, id: API_KEY_DATA_SEED_IDS.ID_1,
name: 'My api key', name: 'My api key',
expiresAt: new Date( expiresAt: new Date(
new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 100, // In 100 years new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 100, // 100 years from now
), ),
}, },
]; ];

View File

@ -5,10 +5,6 @@ import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/wor
import { computeTableName } from 'src/engine/utils/compute-table-name.util'; import { computeTableName } from 'src/engine/utils/compute-table-name.util';
import { shouldSeedWorkspaceFavorite } from 'src/engine/utils/should-seed-workspace-favorite'; import { shouldSeedWorkspaceFavorite } from 'src/engine/utils/should-seed-workspace-favorite';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import {
API_KEY_DATA_SEED_COLUMNS,
API_KEY_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant';
import { import {
CALENDAR_CHANNEL_DATA_SEED_COLUMNS, CALENDAR_CHANNEL_DATA_SEED_COLUMNS,
CALENDAR_CHANNEL_DATA_SEEDS, CALENDAR_CHANNEL_DATA_SEEDS,
@ -130,11 +126,6 @@ const RECORD_SEEDS_CONFIGS = [
pgColumns: OPPORTUNITY_DATA_SEED_COLUMNS, pgColumns: OPPORTUNITY_DATA_SEED_COLUMNS,
recordSeeds: OPPORTUNITY_DATA_SEEDS, recordSeeds: OPPORTUNITY_DATA_SEEDS,
}, },
{
tableName: 'apiKey',
pgColumns: API_KEY_DATA_SEED_COLUMNS,
recordSeeds: API_KEY_DATA_SEEDS,
},
{ {
tableName: 'connectedAccount', tableName: 'connectedAccount',
pgColumns: CONNECTED_ACCOUNT_DATA_SEED_COLUMNS, pgColumns: CONNECTED_ACCOUNT_DATA_SEED_COLUMNS,

View File

@ -49,6 +49,7 @@ export class StandardFieldFactory {
isGatedAndNotEnabled( isGatedAndNotEnabled(
workspaceEntityMetadataArgs.gate, workspaceEntityMetadataArgs.gate,
context.featureFlags, context.featureFlags,
'database',
) )
) { ) {
return acc; return acc;

View File

@ -37,6 +37,7 @@ export class StandardObjectFactory {
isGatedAndNotEnabled( isGatedAndNotEnabled(
workspaceEntityMetadataArgs.gate, workspaceEntityMetadataArgs.gate,
context.featureFlags, context.featureFlags,
'database',
) )
) { ) {
return undefined; return undefined;

View File

@ -37,7 +37,6 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta
// TODO: Maybe we should automate this with the DiscoverService of Nest.JS // TODO: Maybe we should automate this with the DiscoverService of Nest.JS
export const standardObjectMetadataDefinitions = [ export const standardObjectMetadataDefinitions = [
ApiKeyWorkspaceEntity,
AttachmentWorkspaceEntity, AttachmentWorkspaceEntity,
BlocklistWorkspaceEntity, BlocklistWorkspaceEntity,
CalendarEventWorkspaceEntity, CalendarEventWorkspaceEntity,
@ -55,7 +54,6 @@ export const standardObjectMetadataDefinitions = [
ViewFilterGroupWorkspaceEntity, ViewFilterGroupWorkspaceEntity,
ViewSortWorkspaceEntity, ViewSortWorkspaceEntity,
ViewWorkspaceEntity, ViewWorkspaceEntity,
WebhookWorkspaceEntity,
WorkflowWorkspaceEntity, WorkflowWorkspaceEntity,
WorkflowVersionWorkspaceEntity, WorkflowVersionWorkspaceEntity,
WorkflowRunWorkspaceEntity, WorkflowRunWorkspaceEntity,
@ -73,4 +71,6 @@ export const standardObjectMetadataDefinitions = [
PersonWorkspaceEntity, PersonWorkspaceEntity,
TaskWorkspaceEntity, TaskWorkspaceEntity,
TaskTargetWorkspaceEntity, TaskTargetWorkspaceEntity,
ApiKeyWorkspaceEntity,
WebhookWorkspaceEntity,
]; ];

View File

@ -1,11 +1,33 @@
import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface'; import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface';
export type GateContext = 'database' | 'graphql';
export const isGatedAndNotEnabled = ( export const isGatedAndNotEnabled = (
gate: Gate | undefined, gate: Gate | undefined,
workspaceFeatureFlagsMap: Record<string, boolean>, workspaceFeatureFlagsMap: Record<string, boolean>,
context?: GateContext,
): boolean => { ): boolean => {
const featureFlagValue = // If no gate, not gated
gate?.featureFlag && workspaceFeatureFlagsMap[gate.featureFlag]; if (!gate?.featureFlag) {
return false;
}
return gate?.featureFlag !== undefined && !featureFlagValue; // Check if explicitly excluded from the specific context
switch (context) {
case 'database':
if (gate.excludeFromDatabase === false) {
return false; // Not gated for database
}
break;
case 'graphql':
if (gate.excludeFromGraphQL === false) {
return false; // Not gated for GraphQL
}
break;
}
// If context-specific exclusion is true or undefined (default behavior), check the flag
const featureFlagValue = workspaceFeatureFlagsMap[gate.featureFlag];
return !featureFlagValue;
}; };

View File

@ -4,6 +4,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
import { API_KEY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; import { API_KEY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
@ -20,6 +21,11 @@ import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync
labelIdentifierStandardId: API_KEY_STANDARD_FIELD_IDS.name, labelIdentifierStandardId: API_KEY_STANDARD_FIELD_IDS.name,
}) })
@WorkspaceIsSystem() @WorkspaceIsSystem()
@WorkspaceGate({
featureFlag: 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED',
excludeFromDatabase: false,
excludeFromGraphQL: true,
})
export class ApiKeyWorkspaceEntity extends BaseWorkspaceEntity { export class ApiKeyWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceField({ @WorkspaceField({
standardId: API_KEY_STANDARD_FIELD_IDS.name, standardId: API_KEY_STANDARD_FIELD_IDS.name,

View File

@ -1,28 +1,26 @@
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { ArrayContains } from 'typeorm';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { WebhookService } from 'src/engine/core-modules/webhook/webhook.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type'; import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type';
import { import {
CallWebhookJob, CallWebhookJob,
CallWebhookJobData, CallWebhookJobData,
} from 'src/modules/webhook/jobs/call-webhook.job'; } from 'src/modules/webhook/jobs/call-webhook.job';
import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity';
import { removeSecretFromWebhookRecord } from 'src/utils/remove-secret-from-webhook-record';
import { ObjectRecordEventForWebhook } from 'src/modules/webhook/types/object-record-event-for-webhook.type'; import { ObjectRecordEventForWebhook } from 'src/modules/webhook/types/object-record-event-for-webhook.type';
import { removeSecretFromWebhookRecord } from 'src/utils/remove-secret-from-webhook-record';
@Processor(MessageQueue.webhookQueue) @Processor(MessageQueue.webhookQueue)
export class CallWebhookJobsJob { export class CallWebhookJobsJob {
constructor( constructor(
@InjectMessageQueue(MessageQueue.webhookQueue) @InjectMessageQueue(MessageQueue.webhookQueue)
private readonly messageQueueService: MessageQueueService, private readonly messageQueueService: MessageQueueService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly webhookService: WebhookService,
) {} ) {}
@Process(CallWebhookJobsJob.name) @Process(CallWebhookJobsJob.name)
@ -34,22 +32,17 @@ export class CallWebhookJobsJob {
// Also change the openApi schema for webhooks // Also change the openApi schema for webhooks
// packages/twenty-server/src/engine/core-modules/open-api/utils/computeWebhooks.utils.ts // packages/twenty-server/src/engine/core-modules/open-api/utils/computeWebhooks.utils.ts
const webhookRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WebhookWorkspaceEntity>(
workspaceEventBatch.workspaceId,
'webhook',
);
const [nameSingular, operation] = workspaceEventBatch.name.split('.'); const [nameSingular, operation] = workspaceEventBatch.name.split('.');
const webhooks = await webhookRepository.find({ const webhooks = await this.webhookService.findByOperations(
where: [ workspaceEventBatch.workspaceId,
{ operations: ArrayContains([`${nameSingular}.${operation}`]) }, [
{ operations: ArrayContains([`*.${operation}`]) }, `${nameSingular}.${operation}`,
{ operations: ArrayContains([`${nameSingular}.*`]) }, `*.${operation}`,
{ operations: ArrayContains(['*.*']) }, `${nameSingular}.*`,
'*.*',
], ],
}); );
for (const eventData of workspaceEventBatch.events) { for (const eventData of workspaceEventBatch.events) {
const eventName = workspaceEventBatch.name; const eventName = workspaceEventBatch.name;

View File

@ -2,11 +2,12 @@ import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { WebhookModule } from 'src/engine/core-modules/webhook/webhook.module';
import { CallWebhookJobsJob } from 'src/modules/webhook/jobs/call-webhook-jobs.job'; import { CallWebhookJobsJob } from 'src/modules/webhook/jobs/call-webhook-jobs.job';
import { CallWebhookJob } from 'src/modules/webhook/jobs/call-webhook.job'; import { CallWebhookJob } from 'src/modules/webhook/jobs/call-webhook.job';
@Module({ @Module({
imports: [HttpModule, AuditModule], imports: [HttpModule, AuditModule, WebhookModule],
providers: [CallWebhookJobsJob, CallWebhookJob], providers: [CallWebhookJobsJob, CallWebhookJob],
}) })
export class WebhookJobModule {} export class WebhookJobModule {}

View File

@ -1,117 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { WebhookUrlValidationService } from 'src/modules/webhook/query-hooks/webhook-url-validation.service';
describe('WebhookUrlValidationService', () => {
let service: WebhookUrlValidationService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [WebhookUrlValidationService],
}).compile();
service = module.get<WebhookUrlValidationService>(
WebhookUrlValidationService,
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('validateWebhookUrl', () => {
it('should accept valid HTTP URLs', () => {
expect(() => {
service.validateWebhookUrl('http://example.com/webhook');
}).not.toThrow();
});
it('should accept valid HTTPS URLs', () => {
expect(() => {
service.validateWebhookUrl('https://example.com/webhook');
}).not.toThrow();
});
it('should accept URLs with ports', () => {
expect(() => {
service.validateWebhookUrl('http://localhost:3000/webhook');
}).not.toThrow();
});
it('should accept URLs with paths and query parameters', () => {
expect(() => {
service.validateWebhookUrl(
'https://api.example.com/webhooks/receive?token=abc123',
);
}).not.toThrow();
});
it('should reject URLs without scheme', () => {
expect(() => {
service.validateWebhookUrl('example.com/webhook');
}).toThrow(GraphqlQueryRunnerException);
});
it('should reject malformed URLs', () => {
expect(() => {
service.validateWebhookUrl('not-a-url');
}).toThrow(GraphqlQueryRunnerException);
});
it('should reject URLs with FTP scheme', () => {
expect(() => {
service.validateWebhookUrl('ftp://example.com/webhook');
}).toThrow(GraphqlQueryRunnerException);
});
it('should reject URLs with mailto scheme', () => {
expect(() => {
service.validateWebhookUrl('mailto:user@example.com');
}).toThrow(GraphqlQueryRunnerException);
});
it('should reject URLs with custom schemes', () => {
expect(() => {
service.validateWebhookUrl('custom://example.com/webhook');
}).toThrow(GraphqlQueryRunnerException);
});
it('should provide helpful error message for malformed URLs', () => {
try {
service.validateWebhookUrl('example.com/webhook');
fail('Expected exception to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(GraphqlQueryRunnerException);
expect(error.code).toBe(
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
expect(error.message).toContain('Invalid URL: missing scheme');
expect(error.message).toContain('example.com/webhook');
}
});
it('should provide helpful error message for invalid scheme', () => {
try {
service.validateWebhookUrl('ftp://example.com/webhook');
fail('Expected exception to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(GraphqlQueryRunnerException);
expect(error.code).toBe(
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
expect(error.message).toContain('Only HTTP and HTTPS are allowed');
expect(error.message).toContain('ftp:');
}
});
it('should reject empty strings', () => {
expect(() => {
service.validateWebhookUrl('');
}).toThrow(GraphqlQueryRunnerException);
});
});
});

Some files were not shown because too many files have changed in this diff Show More