diff --git a/packages/twenty-emails/src/emails/validate-approved-access-domain.email.tsx b/packages/twenty-emails/src/emails/validate-approved-access-domain.email.tsx new file mode 100644 index 000000000000..40759b453f6d --- /dev/null +++ b/packages/twenty-emails/src/emails/validate-approved-access-domain.email.tsx @@ -0,0 +1,67 @@ +import { t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { Img } from '@react-email/components'; +import { emailTheme } from 'src/common-style'; + +import { BaseEmail } from 'src/components/BaseEmail'; +import { CallToAction } from 'src/components/CallToAction'; +import { HighlightedContainer } from 'src/components/HighlightedContainer'; +import { HighlightedText } from 'src/components/HighlightedText'; +import { Link } from 'src/components/Link'; +import { MainText } from 'src/components/MainText'; +import { Title } from 'src/components/Title'; +import { WhatIsTwenty } from 'src/components/WhatIsTwenty'; +import { capitalize } from 'src/utils/capitalize'; +import { APP_LOCALES, getImageAbsoluteURI } from 'twenty-shared'; + +type SendApprovedAccessDomainValidationProps = { + link: string; + domain: string; + workspace: { name: string | undefined; logo: string | undefined }; + sender: { + email: string; + firstName: string; + lastName: string; + }; + serverUrl: string; + locale: keyof typeof APP_LOCALES; +}; + +export const SendApprovedAccessDomainValidation = ({ + link, + domain, + workspace, + sender, + serverUrl, + locale, +}: SendApprovedAccessDomainValidationProps) => { + const workspaceLogo = workspace.logo + ? getImageAbsoluteURI({ imageUrl: workspace.logo, baseUrl: serverUrl }) + : null; + + return ( + + + <MainText> + {capitalize(sender.firstName)} ( + <Link + href={`mailto:${sender.email}`} + value={sender.email} + color={emailTheme.font.colors.blue} + /> + ) + <Trans> + Please validate this domain to allow users with <b>@{domain}</b> email + addresses to join your workspace without requiring an invitation. + </Trans> + <br /> + </MainText> + <HighlightedContainer> + {workspaceLogo && <Img src={workspaceLogo} width={40} height={40} />} + {workspace.name && <HighlightedText value={workspace.name} />} + <CallToAction href={link} value={t`Validate domain`} /> + </HighlightedContainer> + <WhatIsTwenty /> + </BaseEmail> + ); +}; diff --git a/packages/twenty-emails/src/index.ts b/packages/twenty-emails/src/index.ts index 9c25bea49313..07ff30d92338 100644 --- a/packages/twenty-emails/src/index.ts +++ b/packages/twenty-emails/src/index.ts @@ -4,3 +4,4 @@ export * from './emails/password-update-notify.email'; export * from './emails/send-email-verification-link.email'; export * from './emails/send-invite-link.email'; export * from './emails/warn-suspended-workspace.email'; +export * from './emails/validate-approved-access-domain.email'; diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 59805ee23cd2..cce851177752 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -102,6 +102,14 @@ export type AppTokenEdge = { node: AppToken; }; +export type ApprovedAccessDomain = { + __typename?: 'ApprovedAccessDomain'; + createdAt: Scalars['DateTime']['output']; + domain: Scalars['String']['output']; + id: Scalars['UUID']['output']; + isValidated: Scalars['Boolean']['output']; +}; + export type AuthProviders = { __typename?: 'AuthProviders'; google: Scalars['Boolean']['output']; @@ -305,6 +313,11 @@ export type CreateAppTokenInput = { expiresAt: Scalars['DateTime']['input']; }; +export type CreateApprovedAccessDomainInput = { + domain: Scalars['String']['input']; + email: Scalars['String']['input']; +}; + export type CreateDraftFromWorkflowVersionInput = { /** Workflow ID */ workflowId: Scalars['String']['input']; @@ -428,6 +441,10 @@ export type CustomDomainValidRecords = { records: Array<CustomDomainRecord>; }; +export type DeleteApprovedAccessDomainInput = { + id: Scalars['String']['input']; +}; + export type DeleteOneFieldInput = { /** The id of the field to delete. */ id: Scalars['UUID']['input']; @@ -550,6 +567,7 @@ export enum FeatureFlagKey { IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled', IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled', IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled', + IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled', IsBillingPlansEnabled = 'IsBillingPlansEnabled', IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled', IsCopilotEnabled = 'IsCopilotEnabled', @@ -839,6 +857,7 @@ export type Mutation = { checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']['output']; + createApprovedAccessDomain: ApprovedAccessDomain; createDraftFromWorkflowVersion: WorkflowVersion; createOIDCIdentityProvider: SetupSsoOutput; createOneAppToken: AppToken; @@ -850,6 +869,7 @@ export type Mutation = { createSAMLIdentityProvider: SetupSsoOutput; createWorkflowVersionStep: WorkflowAction; deactivateWorkflowVersion: Scalars['Boolean']['output']; + deleteApprovedAccessDomain: Scalars['Boolean']['output']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; deleteOneObject: Object; @@ -900,6 +920,7 @@ export type Mutation = { uploadProfilePicture: Scalars['String']['output']; uploadWorkspaceLogo: Scalars['String']['output']; userLookupAdminPanel: UserLookup; + validateApprovedAccessDomain: ApprovedAccessDomain; }; @@ -938,6 +959,11 @@ export type MutationComputeStepOutputSchemaArgs = { }; +export type MutationCreateApprovedAccessDomainArgs = { + input: CreateApprovedAccessDomainInput; +}; + + export type MutationCreateDraftFromWorkflowVersionArgs = { input: CreateDraftFromWorkflowVersionInput; }; @@ -993,6 +1019,11 @@ export type MutationDeactivateWorkflowVersionArgs = { }; +export type MutationDeleteApprovedAccessDomainArgs = { + input: DeleteApprovedAccessDomainInput; +}; + + export type MutationDeleteOneFieldArgs = { input: DeleteOneFieldInput; }; @@ -1225,6 +1256,11 @@ export type MutationUserLookupAdminPanelArgs = { userIdentifier: Scalars['String']['input']; }; + +export type MutationValidateApprovedAccessDomainArgs = { + input: ValidateApprovedAccessDomainInput; +}; + export type Object = { __typename?: 'Object'; createdAt: Scalars['DateTime']['output']; @@ -1393,6 +1429,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array<WorkspaceInvitation>; + getApprovedAccessDomains: Array<ApprovedAccessDomain>; getAvailablePackages: Scalars['JSON']['output']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getIndicatorHealthStatus: AdminPanelHealthServiceData; @@ -2112,6 +2149,11 @@ export type UserWorkspace = { workspaceId: Scalars['String']['output']; }; +export type ValidateApprovedAccessDomainInput = { + approvedAccessDomainId: Scalars['String']['input']; + validationToken: Scalars['String']['input']; +}; + export type ValidatePasswordResetToken = { __typename?: 'ValidatePasswordResetToken'; email: Scalars['String']['output']; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 2626d2bfce45..1a745e664d44 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -95,6 +95,14 @@ export type AppTokenEdge = { node: AppToken; }; +export type ApprovedAccessDomain = { + __typename?: 'ApprovedAccessDomain'; + createdAt: Scalars['DateTime']; + domain: Scalars['String']; + id: Scalars['UUID']; + isValidated: Scalars['Boolean']; +}; + export type AuthProviders = { __typename?: 'AuthProviders'; google: Scalars['Boolean']; @@ -294,6 +302,11 @@ export type ComputeStepOutputSchemaInput = { step: Scalars['JSON']; }; +export type CreateApprovedAccessDomainInput = { + domain: Scalars['String']; + email: Scalars['String']; +}; + export type CreateDraftFromWorkflowVersionInput = { /** Workflow ID */ workflowId: Scalars['String']; @@ -365,6 +378,10 @@ export type CustomDomainValidRecords = { records: Array<CustomDomainRecord>; }; +export type DeleteApprovedAccessDomainInput = { + id: Scalars['String']; +}; + export type DeleteOneFieldInput = { /** The id of the field to delete. */ id: Scalars['UUID']; @@ -482,6 +499,7 @@ export enum FeatureFlagKey { IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled', IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled', IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled', + IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled', IsBillingPlansEnabled = 'IsBillingPlansEnabled', IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled', IsCopilotEnabled = 'IsCopilotEnabled', @@ -764,6 +782,7 @@ export type Mutation = { checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']; + createApprovedAccessDomain: ApprovedAccessDomain; createDraftFromWorkflowVersion: WorkflowVersion; createOIDCIdentityProvider: SetupSsoOutput; createOneAppToken: AppToken; @@ -773,6 +792,7 @@ export type Mutation = { createSAMLIdentityProvider: SetupSsoOutput; createWorkflowVersionStep: WorkflowAction; deactivateWorkflowVersion: Scalars['Boolean']; + deleteApprovedAccessDomain: Scalars['Boolean']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; deleteOneObject: Object; @@ -817,6 +837,7 @@ export type Mutation = { uploadProfilePicture: Scalars['String']; uploadWorkspaceLogo: Scalars['String']; userLookupAdminPanel: UserLookup; + validateApprovedAccessDomain: ApprovedAccessDomain; }; @@ -855,6 +876,11 @@ export type MutationComputeStepOutputSchemaArgs = { }; +export type MutationCreateApprovedAccessDomainArgs = { + input: CreateApprovedAccessDomainInput; +}; + + export type MutationCreateDraftFromWorkflowVersionArgs = { input: CreateDraftFromWorkflowVersionInput; }; @@ -890,6 +916,11 @@ export type MutationDeactivateWorkflowVersionArgs = { }; +export type MutationDeleteApprovedAccessDomainArgs = { + input: DeleteApprovedAccessDomainInput; +}; + + export type MutationDeleteOneFieldArgs = { input: DeleteOneFieldInput; }; @@ -1092,6 +1123,11 @@ export type MutationUserLookupAdminPanelArgs = { userIdentifier: Scalars['String']; }; + +export type MutationValidateApprovedAccessDomainArgs = { + input: ValidateApprovedAccessDomainInput; +}; + export type Object = { __typename?: 'Object'; createdAt: Scalars['DateTime']; @@ -1257,6 +1293,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array<WorkspaceInvitation>; + getApprovedAccessDomains: Array<ApprovedAccessDomain>; getAvailablePackages: Scalars['JSON']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getIndicatorHealthStatus: AdminPanelHealthServiceData; @@ -1890,6 +1927,11 @@ export type UserWorkspace = { workspaceId: Scalars['String']; }; +export type ValidateApprovedAccessDomainInput = { + approvedAccessDomainId: Scalars['String']; + validationToken: Scalars['String']; +}; + export type ValidatePasswordResetToken = { __typename?: 'ValidatePasswordResetToken'; email: Scalars['String']; @@ -2336,6 +2378,13 @@ export type GetRolesQueryVariables = Exact<{ [key: string]: never; }>; export type GetRolesQuery = { __typename?: 'Query', getRoles: Array<{ __typename?: 'Role', id: string, label: string, description?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, workspaceMembers: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> }> }; +export type CreateApprovedAccessDomainMutationVariables = Exact<{ + input: CreateApprovedAccessDomainInput; +}>; + + +export type CreateApprovedAccessDomainMutation = { __typename?: 'Mutation', createApprovedAccessDomain: { __typename?: 'ApprovedAccessDomain', id: any, domain: string, isValidated: boolean, createdAt: string } }; + export type CreateOidcIdentityProviderMutationVariables = Exact<{ input: SetupOidcSsoInput; }>; @@ -2350,6 +2399,13 @@ export type CreateSamlIdentityProviderMutationVariables = Exact<{ export type CreateSamlIdentityProviderMutation = { __typename?: 'Mutation', createSAMLIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; +export type DeleteApprovedAccessDomainMutationVariables = Exact<{ + input: DeleteApprovedAccessDomainInput; +}>; + + +export type DeleteApprovedAccessDomainMutation = { __typename?: 'Mutation', deleteApprovedAccessDomain: boolean }; + export type DeleteSsoIdentityProviderMutationVariables = Exact<{ input: DeleteSsoInput; }>; @@ -2364,6 +2420,18 @@ export type EditSsoIdentityProviderMutationVariables = Exact<{ export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; +export type ValidateApprovedAccessDomainMutationVariables = Exact<{ + input: ValidateApprovedAccessDomainInput; +}>; + + +export type ValidateApprovedAccessDomainMutation = { __typename?: 'Mutation', validateApprovedAccessDomain: { __typename?: 'ApprovedAccessDomain', id: any, isValidated: boolean, domain: string, createdAt: string } }; + +export type GetApprovedAccessDomainsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetApprovedAccessDomainsQuery = { __typename?: 'Query', getApprovedAccessDomains: Array<{ __typename?: 'ApprovedAccessDomain', id: any, createdAt: string, domain: string, isValidated: boolean }> }; + export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never; }>; @@ -4252,6 +4320,42 @@ export function useGetRolesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<G export type GetRolesQueryHookResult = ReturnType<typeof useGetRolesQuery>; export type GetRolesLazyQueryHookResult = ReturnType<typeof useGetRolesLazyQuery>; export type GetRolesQueryResult = Apollo.QueryResult<GetRolesQuery, GetRolesQueryVariables>; +export const CreateApprovedAccessDomainDocument = gql` + mutation CreateApprovedAccessDomain($input: CreateApprovedAccessDomainInput!) { + createApprovedAccessDomain(input: $input) { + id + domain + isValidated + createdAt + } +} + `; +export type CreateApprovedAccessDomainMutationFn = Apollo.MutationFunction<CreateApprovedAccessDomainMutation, CreateApprovedAccessDomainMutationVariables>; + +/** + * __useCreateApprovedAccessDomainMutation__ + * + * To run a mutation, you first call `useCreateApprovedAccessDomainMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateApprovedAccessDomainMutation` 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 [createApprovedAccessDomainMutation, { data, loading, error }] = useCreateApprovedAccessDomainMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateApprovedAccessDomainMutation(baseOptions?: Apollo.MutationHookOptions<CreateApprovedAccessDomainMutation, CreateApprovedAccessDomainMutationVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation<CreateApprovedAccessDomainMutation, CreateApprovedAccessDomainMutationVariables>(CreateApprovedAccessDomainDocument, options); + } +export type CreateApprovedAccessDomainMutationHookResult = ReturnType<typeof useCreateApprovedAccessDomainMutation>; +export type CreateApprovedAccessDomainMutationResult = Apollo.MutationResult<CreateApprovedAccessDomainMutation>; +export type CreateApprovedAccessDomainMutationOptions = Apollo.BaseMutationOptions<CreateApprovedAccessDomainMutation, CreateApprovedAccessDomainMutationVariables>; export const CreateOidcIdentityProviderDocument = gql` mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) { createOIDCIdentityProvider(input: $input) { @@ -4326,6 +4430,37 @@ export function useCreateSamlIdentityProviderMutation(baseOptions?: Apollo.Mutat export type CreateSamlIdentityProviderMutationHookResult = ReturnType<typeof useCreateSamlIdentityProviderMutation>; export type CreateSamlIdentityProviderMutationResult = Apollo.MutationResult<CreateSamlIdentityProviderMutation>; export type CreateSamlIdentityProviderMutationOptions = Apollo.BaseMutationOptions<CreateSamlIdentityProviderMutation, CreateSamlIdentityProviderMutationVariables>; +export const DeleteApprovedAccessDomainDocument = gql` + mutation DeleteApprovedAccessDomain($input: DeleteApprovedAccessDomainInput!) { + deleteApprovedAccessDomain(input: $input) +} + `; +export type DeleteApprovedAccessDomainMutationFn = Apollo.MutationFunction<DeleteApprovedAccessDomainMutation, DeleteApprovedAccessDomainMutationVariables>; + +/** + * __useDeleteApprovedAccessDomainMutation__ + * + * To run a mutation, you first call `useDeleteApprovedAccessDomainMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteApprovedAccessDomainMutation` 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 [deleteApprovedAccessDomainMutation, { data, loading, error }] = useDeleteApprovedAccessDomainMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useDeleteApprovedAccessDomainMutation(baseOptions?: Apollo.MutationHookOptions<DeleteApprovedAccessDomainMutation, DeleteApprovedAccessDomainMutationVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation<DeleteApprovedAccessDomainMutation, DeleteApprovedAccessDomainMutationVariables>(DeleteApprovedAccessDomainDocument, options); + } +export type DeleteApprovedAccessDomainMutationHookResult = ReturnType<typeof useDeleteApprovedAccessDomainMutation>; +export type DeleteApprovedAccessDomainMutationResult = Apollo.MutationResult<DeleteApprovedAccessDomainMutation>; +export type DeleteApprovedAccessDomainMutationOptions = Apollo.BaseMutationOptions<DeleteApprovedAccessDomainMutation, DeleteApprovedAccessDomainMutationVariables>; export const DeleteSsoIdentityProviderDocument = gql` mutation DeleteSSOIdentityProvider($input: DeleteSsoInput!) { deleteSSOIdentityProvider(input: $input) { @@ -4396,6 +4531,79 @@ export function useEditSsoIdentityProviderMutation(baseOptions?: Apollo.Mutation export type EditSsoIdentityProviderMutationHookResult = ReturnType<typeof useEditSsoIdentityProviderMutation>; export type EditSsoIdentityProviderMutationResult = Apollo.MutationResult<EditSsoIdentityProviderMutation>; export type EditSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions<EditSsoIdentityProviderMutation, EditSsoIdentityProviderMutationVariables>; +export const ValidateApprovedAccessDomainDocument = gql` + mutation ValidateApprovedAccessDomain($input: ValidateApprovedAccessDomainInput!) { + validateApprovedAccessDomain(input: $input) { + id + isValidated + domain + createdAt + } +} + `; +export type ValidateApprovedAccessDomainMutationFn = Apollo.MutationFunction<ValidateApprovedAccessDomainMutation, ValidateApprovedAccessDomainMutationVariables>; + +/** + * __useValidateApprovedAccessDomainMutation__ + * + * To run a mutation, you first call `useValidateApprovedAccessDomainMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useValidateApprovedAccessDomainMutation` 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 [validateApprovedAccessDomainMutation, { data, loading, error }] = useValidateApprovedAccessDomainMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useValidateApprovedAccessDomainMutation(baseOptions?: Apollo.MutationHookOptions<ValidateApprovedAccessDomainMutation, ValidateApprovedAccessDomainMutationVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation<ValidateApprovedAccessDomainMutation, ValidateApprovedAccessDomainMutationVariables>(ValidateApprovedAccessDomainDocument, options); + } +export type ValidateApprovedAccessDomainMutationHookResult = ReturnType<typeof useValidateApprovedAccessDomainMutation>; +export type ValidateApprovedAccessDomainMutationResult = Apollo.MutationResult<ValidateApprovedAccessDomainMutation>; +export type ValidateApprovedAccessDomainMutationOptions = Apollo.BaseMutationOptions<ValidateApprovedAccessDomainMutation, ValidateApprovedAccessDomainMutationVariables>; +export const GetApprovedAccessDomainsDocument = gql` + query GetApprovedAccessDomains { + getApprovedAccessDomains { + id + createdAt + domain + isValidated + } +} + `; + +/** + * __useGetApprovedAccessDomainsQuery__ + * + * To run a query within a React component, call `useGetApprovedAccessDomainsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetApprovedAccessDomainsQuery` 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 } = useGetApprovedAccessDomainsQuery({ + * variables: { + * }, + * }); + */ +export function useGetApprovedAccessDomainsQuery(baseOptions?: Apollo.QueryHookOptions<GetApprovedAccessDomainsQuery, GetApprovedAccessDomainsQueryVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery<GetApprovedAccessDomainsQuery, GetApprovedAccessDomainsQueryVariables>(GetApprovedAccessDomainsDocument, options); + } +export function useGetApprovedAccessDomainsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetApprovedAccessDomainsQuery, GetApprovedAccessDomainsQueryVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery<GetApprovedAccessDomainsQuery, GetApprovedAccessDomainsQueryVariables>(GetApprovedAccessDomainsDocument, options); + } +export type GetApprovedAccessDomainsQueryHookResult = ReturnType<typeof useGetApprovedAccessDomainsQuery>; +export type GetApprovedAccessDomainsLazyQueryHookResult = ReturnType<typeof useGetApprovedAccessDomainsLazyQuery>; +export type GetApprovedAccessDomainsQueryResult = Apollo.QueryResult<GetApprovedAccessDomainsQuery, GetApprovedAccessDomainsQueryVariables>; export const GetSsoIdentityProvidersDocument = gql` query GetSSOIdentityProviders { getSSOIdentityProviders { diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index d3e6448e164e..4d549ccad4c8 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -234,6 +234,14 @@ const SettingsSecuritySSOIdentifyProvider = lazy(() => ), ); +const SettingsSecurityApprovedAccessDomain = lazy(() => + import('~/pages/settings/security/SettingsSecurityApprovedAccessDomain').then( + (module) => ({ + default: module.SettingsSecurityApprovedAccessDomain, + }), + ), +); + const SettingsAdmin = lazy(() => import('~/pages/settings/admin-panel/SettingsAdmin').then((module) => ({ default: module.SettingsAdmin, @@ -408,6 +416,11 @@ export const SettingsRoutes = ({ path={SettingsPath.NewSSOIdentityProvider} element={<SettingsSecuritySSOIdentifyProvider />} /> + <Route + path={SettingsPath.NewApprovedAccessDomain} + element={<SettingsSecurityApprovedAccessDomain />} + /> + {isAdminPageEnabled && ( <> <Route path={SettingsPath.AdminPanel} element={<SettingsAdmin />} /> diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersForm.tsx similarity index 98% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersForm.tsx index a54dd760e430..508dc6fbc2e2 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersForm.tsx @@ -2,8 +2,8 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsRadioCardContainer } from '@/settings/components/SettingsRadioCardContainer'; -import { SettingsSSOOIDCForm } from '@/settings/security/components/SettingsSSOOIDCForm'; -import { SettingsSSOSAMLForm } from '@/settings/security/components/SettingsSSOSAMLForm'; +import { SettingsSSOOIDCForm } from '@/settings/security/components/SSO/SettingsSSOOIDCForm'; +import { SettingsSSOSAMLForm } from '@/settings/security/components/SSO/SettingsSSOSAMLForm'; import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider'; import { TextInput } from '@/ui/input/components/TextInput'; import styled from '@emotion/styled'; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard.tsx similarity index 96% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard.tsx index 2d7343664731..6b2349ca5f41 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard.tsx @@ -6,7 +6,7 @@ import { SettingsPath } from '@/types/SettingsPath'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { SettingsCard } from '@/settings/components/SettingsCard'; -import { SettingsSSOIdentitiesProvidersListCardWrapper } from '@/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper'; +import { SettingsSSOIdentitiesProvidersListCardWrapper } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCardWrapper'; import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCardWrapper.tsx similarity index 94% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCardWrapper.tsx index 5c2ee3ce8835..c6dddf82febe 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCardWrapper.tsx @@ -1,7 +1,7 @@ /* @license Enterprise */ import { SettingsListCard } from '@/settings/components/SettingsListCard'; -import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/security/components/SettingsSSOIdentityProviderRowRightContainer'; +import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/security/components/SSO/SettingsSSOIdentityProviderRowRightContainer'; import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState'; import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl'; import { SettingsPath } from '@/types/SettingsPath'; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentityProviderRowRightContainer.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentityProviderRowRightContainer.tsx similarity index 94% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentityProviderRowRightContainer.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentityProviderRowRightContainer.tsx index fb55040682c3..d90a72ebc8d2 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentityProviderRowRightContainer.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentityProviderRowRightContainer.tsx @@ -1,6 +1,6 @@ /* @license Enterprise */ -import { SettingsSecuritySSORowDropdownMenu } from '@/settings/security/components/SettingsSecuritySSORowDropdownMenu'; +import { SettingsSecuritySSORowDropdownMenu } from '@/settings/security/components/SSO/SettingsSecuritySSORowDropdownMenu'; import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState'; import { getColorBySSOIdentityProviderStatus } from '@/settings/security/utils/getColorBySSOIdentityProviderStatus'; import { Status } from 'twenty-ui'; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOOIDCForm.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx similarity index 100% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOOIDCForm.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOSAMLForm.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOSAMLForm.tsx similarity index 100% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOSAMLForm.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOSAMLForm.tsx diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecuritySSORowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSecuritySSORowDropdownMenu.tsx similarity index 100% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSecuritySSORowDropdownMenu.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSecuritySSORowDropdownMenu.tsx diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthProvidersOptionsList.tsx similarity index 98% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx rename to packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthProvidersOptionsList.tsx index ceb45356dab0..5684e081019a 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthProvidersOptionsList.tsx @@ -24,7 +24,7 @@ const StyledSettingsSecurityOptionsList = styled.div` gap: ${({ theme }) => theme.spacing(4)}; `; -export const SettingsSecurityOptionsList = () => { +export const SettingsSecurityAuthProvidersOptionsList = () => { const { t } = useLingui(); const { enqueueSnackBar } = useSnackBar(); diff --git a/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard.tsx b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard.tsx new file mode 100644 index 000000000000..c2de573b22fe --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard.tsx @@ -0,0 +1,73 @@ +import { Link, useNavigate } from 'react-router-dom'; + +import { SettingsPath } from '@/types/SettingsPath'; + +import { SettingsCard } from '@/settings/components/SettingsCard'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import styled from '@emotion/styled'; +import { useLingui } from '@lingui/react/macro'; +import { useRecoilState } from 'recoil'; +import { IconAt, IconMailCog } from 'twenty-ui'; +import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; +import { SettingsListCard } from '@/settings/components/SettingsListCard'; +import { approvedAccessDomainsState } from '@/settings/security/states/ApprovedAccessDomainsState'; +import { SettingsSecurityApprovedAccessDomainRowDropdownMenu } from '@/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu'; +import { SettingsSecurityApprovedAccessDomainValidationEffect } from '@/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect'; +import { useGetApprovedAccessDomainsQuery } from '~/generated/graphql'; + +const StyledLink = styled(Link)` + text-decoration: none; +`; + +export const SettingsApprovedAccessDomainsListCard = () => { + const { enqueueSnackBar } = useSnackBar(); + const navigate = useNavigate(); + const { t } = useLingui(); + + const [approvedAccessDomains, setApprovedAccessDomains] = useRecoilState( + approvedAccessDomainsState, + ); + + const { loading } = useGetApprovedAccessDomainsQuery({ + fetchPolicy: 'network-only', + onCompleted: (data) => { + setApprovedAccessDomains(data?.getApprovedAccessDomains ?? []); + }, + onError: (error: Error) => { + enqueueSnackBar(error.message, { + variant: SnackBarVariant.Error, + }); + }, + }); + + return loading || !approvedAccessDomains.length ? ( + <StyledLink to={getSettingsPath(SettingsPath.NewApprovedAccessDomain)}> + <SettingsCard + title={t`Add Approved Access Domain`} + Icon={<IconMailCog />} + /> + </StyledLink> + ) : ( + <> + <SettingsSecurityApprovedAccessDomainValidationEffect /> + <SettingsListCard + items={approvedAccessDomains} + getItemLabel={(approvedAccessDomain) => + `${approvedAccessDomain.domain} - ${approvedAccessDomain.createdAt}` + } + RowIcon={IconAt} + RowRightComponent={({ item: approvedAccessDomain }) => ( + <SettingsSecurityApprovedAccessDomainRowDropdownMenu + approvedAccessDomain={approvedAccessDomain} + /> + )} + hasFooter + footerButtonLabel="Add Approved Access Domain" + onFooterButtonClick={() => + navigate(getSettingsPath(SettingsPath.NewApprovedAccessDomain)) + } + /> + </> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx new file mode 100644 index 000000000000..4629defdd7f8 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx @@ -0,0 +1,84 @@ +import { + IconDotsVertical, + IconTrash, + LightIconButton, + MenuItem, +} from 'twenty-ui'; + +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; +import { UnwrapRecoilValue, useSetRecoilState } from 'recoil'; +import { isDefined } from 'twenty-shared'; +import { useDeleteApprovedAccessDomainMutation } from '~/generated/graphql'; +import { approvedAccessDomainsState } from '@/settings/security/states/ApprovedAccessDomainsState'; + +type SettingsSecurityApprovedAccessDomainRowDropdownMenuProps = { + approvedAccessDomain: UnwrapRecoilValue<typeof approvedAccessDomainsState>[0]; +}; + +export const SettingsSecurityApprovedAccessDomainRowDropdownMenu = ({ + approvedAccessDomain, +}: SettingsSecurityApprovedAccessDomainRowDropdownMenuProps) => { + const dropdownId = `settings-approved-access-domain-row-${approvedAccessDomain.id}`; + + const setApprovedAccessDomains = useSetRecoilState( + approvedAccessDomainsState, + ); + + const { enqueueSnackBar } = useSnackBar(); + + const { closeDropdown } = useDropdown(dropdownId); + + const [deleteApprovedAccessDomain] = useDeleteApprovedAccessDomainMutation(); + + const handleDeleteApprovedAccessDomain = async () => { + const result = await deleteApprovedAccessDomain({ + variables: { + input: { + id: approvedAccessDomain.id, + }, + }, + onCompleted: () => { + setApprovedAccessDomains((approvedAccessDomains) => { + return approvedAccessDomains.filter( + ({ id }) => id !== approvedAccessDomain.id, + ); + }); + }, + }); + if (isDefined(result.errors)) { + enqueueSnackBar('Error deleting approved access domain', { + variant: SnackBarVariant.Error, + duration: 2000, + }); + } + }; + + return ( + <Dropdown + dropdownId={dropdownId} + dropdownPlacement="right-start" + dropdownHotkeyScope={{ scope: dropdownId }} + clickableComponent={ + <LightIconButton Icon={IconDotsVertical} accent="tertiary" /> + } + dropdownMenuWidth={160} + dropdownComponents={ + <DropdownMenuItemsContainer> + <MenuItem + accent="danger" + LeftIcon={IconTrash} + text="Delete" + onClick={() => { + handleDeleteApprovedAccessDomain(); + closeDropdown(); + }} + /> + </DropdownMenuItemsContainer> + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx new file mode 100644 index 000000000000..a37761aca7ab --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { isDefined } from 'twenty-shared'; +import { useValidateApprovedAccessDomainMutation } from '~/generated/graphql'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useSearchParams } from 'react-router-dom'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; + +export const SettingsSecurityApprovedAccessDomainValidationEffect = () => { + const [validateApprovedAccessDomainMutation] = + useValidateApprovedAccessDomainMutation(); + const { enqueueSnackBar } = useSnackBar(); + const [searchParams] = useSearchParams(); + const approvedAccessDomainId = searchParams.get('wtdId'); + const validationToken = searchParams.get('validationToken'); + + useEffect(() => { + if (isDefined(validationToken) && isDefined(approvedAccessDomainId)) { + validateApprovedAccessDomainMutation({ + variables: { + input: { + validationToken, + approvedAccessDomainId, + }, + }, + onCompleted: () => { + enqueueSnackBar('Approved access domain validated', { + dedupeKey: 'approved-access-domain-validation-dedupe-key', + variant: SnackBarVariant.Success, + }); + }, + onError: () => { + enqueueSnackBar('Error validating approved access domain', { + dedupeKey: 'approved-access-domain-validation-error-dedupe-key', + variant: SnackBarVariant.Error, + }); + }, + }); + } + // Validate approved access domain only needs to run once at mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return <></>; +}; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/createApprovedAccessDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createApprovedAccessDomain.ts new file mode 100644 index 000000000000..6d23aa3a3325 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createApprovedAccessDomain.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const CREATE_APPROVED_ACCESS_DOMAIN = gql` + mutation CreateApprovedAccessDomain( + $input: CreateApprovedAccessDomainInput! + ) { + createApprovedAccessDomain(input: $input) { + id + domain + isValidated + createdAt + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteApprovedAccessDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteApprovedAccessDomain.ts new file mode 100644 index 000000000000..a410bbed2152 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteApprovedAccessDomain.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const DELETE_APPROVED_ACCESS_DOMAIN = gql` + mutation DeleteApprovedAccessDomain( + $input: DeleteApprovedAccessDomainInput! + ) { + deleteApprovedAccessDomain(input: $input) + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/validateApprovedAccessDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/validateApprovedAccessDomain.ts new file mode 100644 index 000000000000..76b910dc2de4 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/validateApprovedAccessDomain.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const VALIDATE_APPROVED_ACCESS_DOMAIN = gql` + mutation ValidateApprovedAccessDomain( + $input: ValidateApprovedAccessDomainInput! + ) { + validateApprovedAccessDomain(input: $input) { + id + isValidated + domain + createdAt + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/queries/getApprovedAccessDomains.ts b/packages/twenty-front/src/modules/settings/security/graphql/queries/getApprovedAccessDomains.ts new file mode 100644 index 000000000000..056ca9630c85 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/queries/getApprovedAccessDomains.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +export const GET_ALL_APPROVED_ACCESS_DOMAINS = gql` + query GetApprovedAccessDomains { + getApprovedAccessDomains { + id + createdAt + domain + isValidated + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/states/ApprovedAccessDomainsState.ts b/packages/twenty-front/src/modules/settings/security/states/ApprovedAccessDomainsState.ts new file mode 100644 index 000000000000..ca0ddc79f6fa --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/states/ApprovedAccessDomainsState.ts @@ -0,0 +1,9 @@ +import { createState } from '@ui/utilities/state/utils/createState'; +import { ApprovedAccessDomain } from '~/generated/graphql'; + +export const approvedAccessDomainsState = createState< + Omit<ApprovedAccessDomain, '__typename'>[] +>({ + key: 'ApprovedAccessDomainsState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts index 6130640aec5e..6cebac0d7a62 100644 --- a/packages/twenty-front/src/modules/types/SettingsPath.ts +++ b/packages/twenty-front/src/modules/types/SettingsPath.ts @@ -29,7 +29,9 @@ export enum SettingsPath { IntegrationNewDatabaseConnection = 'integrations/:databaseKey/new', Security = 'security', NewSSOIdentityProvider = 'security/sso/new', - EditSSOIdentityProvider = 'security/sso/:identityProviderId', + NewApprovedAccessDomain = 'security/approved-access-domain/new', + Webhooks = 'webhooks', + DevelopersNewWebhook = 'developers/webhooks/new', DevelopersNewWebhookDetail = 'developers/webhooks/:webhookId', Releases = 'releases', AdminPanel = 'admin-panel', diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx index 3e361abac1bd..8a2becdc39ba 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -4,11 +4,14 @@ import { H2Title, IconLock, Section, Tag } from 'twenty-ui'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsReadDocumentationButton } from '@/settings/developers/components/SettingsReadDocumentationButton'; -import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SettingsSSOIdentitiesProvidersListCard'; -import { SettingsSecurityOptionsList } from '@/settings/security/components/SettingsSecurityOptionsList'; +import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard'; +import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList'; import { SettingsPath } from '@/types/SettingsPath'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; +import { SettingsApprovedAccessDomainsListCard } from '@/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated/graphql'; const StyledContainer = styled.div` width: 100%; @@ -21,13 +24,17 @@ const StyledMainContent = styled.div` min-height: 200px; `; -const StyledSSOSection = styled(Section)` +const StyledSection = styled(Section)` flex-shrink: 0; `; export const SettingsSecurity = () => { const { t } = useLingui(); + const IsApprovedAccessDomainsEnabled = useIsFeatureEnabled( + FeatureFlagKey.IsApprovedAccessDomainsEnabled, + ); + return ( <SubMenuTopBarContainer title={t`Security`} @@ -42,7 +49,7 @@ export const SettingsSecurity = () => { > <SettingsPageContainer> <StyledMainContent> - <StyledSSOSection> + <StyledSection> <H2Title title={t`SSO`} description={t`Configure an SSO connection`} @@ -56,14 +63,23 @@ export const SettingsSecurity = () => { } /> <SettingsSSOIdentitiesProvidersListCard /> - </StyledSSOSection> + </StyledSection> + {IsApprovedAccessDomainsEnabled && ( + <StyledSection> + <H2Title + title={t`Approved Email Domain`} + description={t`Anyone with an email address at these domains is allowed to sign up for this workspace.`} + /> + <SettingsApprovedAccessDomainsListCard /> + </StyledSection> + )} <Section> <StyledContainer> <H2Title title={t`Authentication`} description={t`Customize your workspace security`} /> - <SettingsSecurityOptionsList /> + <SettingsSecurityAuthProvidersOptionsList /> </StyledContainer> </Section> </StyledMainContent> diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx new file mode 100644 index 000000000000..9934eb959553 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx @@ -0,0 +1,152 @@ +import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; +import { SettingsPath } from '@/types/SettingsPath'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Controller, useForm } from 'react-hook-form'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { TextInput } from '@/ui/input/components/TextInput'; +import { z } from 'zod'; +import { H2Title, Section } from 'twenty-ui'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { useCreateApprovedAccessDomainMutation } from '~/generated/graphql'; + +export const SettingsSecurityApprovedAccessDomain = () => { + const navigate = useNavigateSettings(); + + const { t } = useLingui(); + + const { enqueueSnackBar } = useSnackBar(); + + const [createApprovedAccessDomain] = useCreateApprovedAccessDomainMutation(); + + const formConfig = useForm<{ domain: string; email: string }>({ + mode: 'onSubmit', + resolver: zodResolver( + z + .object({ + domain: z + .string() + .regex( + /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/, + { + message: t`Invalid domain. Domains have to be smaller than 256 characters in length, cannot be IP addresses, cannot contain spaces, cannot contain any special characters such as _~\`!@#$%^*()=+{}[]|\\;:'",<>/? and cannot begin or end with a '-' character.`, + }, + ) + .max(256), + email: z.string().min(1, { + message: t`Email can not be empty`, + }), + }) + .strict(), + ), + defaultValues: { + email: '', + domain: '', + }, + }); + + const domain = formConfig.watch('domain'); + + const handleSave = async () => { + try { + if (!formConfig.formState.isValid) { + return; + } + createApprovedAccessDomain({ + variables: { + input: { + domain: formConfig.getValues('domain'), + email: + formConfig.getValues('email') + + '@' + + formConfig.getValues('domain'), + }, + }, + onCompleted: () => { + enqueueSnackBar(t`Domain added successfully.`, { + variant: SnackBarVariant.Success, + }); + navigate(SettingsPath.Security); + }, + onError: (error) => { + enqueueSnackBar((error as Error).message, { + variant: SnackBarVariant.Error, + }); + }, + }); + } catch (error) { + enqueueSnackBar((error as Error).message, { + variant: SnackBarVariant.Error, + }); + } + }; + + return ( + <SubMenuTopBarContainer + title="New Approved Access Domain" + actionButton={ + <SaveAndCancelButtons + onCancel={() => navigate(SettingsPath.Security)} + onSave={formConfig.handleSubmit(handleSave)} + /> + } + links={[ + { + children: <Trans>Workspace</Trans>, + href: getSettingsPath(SettingsPath.Workspace), + }, + { + children: <Trans>Security</Trans>, + href: getSettingsPath(SettingsPath.Security), + }, + { children: <Trans>New Approved Access Domain</Trans> }, + ]} + > + <SettingsPageContainer> + <Section> + <H2Title title="Domain" description="The name of your Domain" /> + <Controller + name="domain" + control={formConfig.control} + render={({ field: { onChange, value }, fieldState: { error } }) => ( + <TextInput + autoComplete="off" + value={value} + onChange={(domain: string) => { + onChange(domain); + }} + fullWidth + placeholder="yourdomain.com" + error={error?.message} + /> + )} + /> + </Section> + <Section> + <H2Title + title="Email verification" + description="We will send your a link to verify domain ownership" + /> + <Controller + name="email" + control={formConfig.control} + render={({ field: { onChange, value }, fieldState: { error } }) => ( + <TextInput + autoComplete="off" + value={value.split('@')[0]} + onChange={onChange} + fullWidth + error={error?.message} + /> + )} + /> + {domain} + </Section> + </SettingsPageContainer> + </SubMenuTopBarContainer> + ); +}; diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx index d44b164a7c60..f54d5f557ce9 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx @@ -1,7 +1,7 @@ /* @license Enterprise */ import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; -import SettingsSSOIdentitiesProvidersForm from '@/settings/security/components/SettingsSSOIdentitiesProvidersForm'; +import SettingsSSOIdentitiesProvidersForm from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersForm'; import { useCreateSSOIdentityProvider } from '@/settings/security/hooks/useCreateSSOIdentityProvider'; import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider'; import { sSOIdentityProviderDefaultValues } from '@/settings/security/utils/sSOIdentityProviderDefaultValues'; @@ -11,6 +11,7 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans } from '@lingui/react/macro'; import pick from 'lodash.pick'; import { FormProvider, useForm } from 'react-hook-form'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; @@ -64,14 +65,14 @@ export const SettingsSecuritySSOIdentifyProvider = () => { } links={[ { - children: 'Workspace', + children: <Trans>Workspace</Trans>, href: getSettingsPath(SettingsPath.Workspace), }, { - children: 'Security', + children: <Trans>Security</Trans>, href: getSettingsPath(SettingsPath.Security), }, - { children: 'New' }, + { children: <Trans>New SSO provider</Trans> }, ]} > <FormProvider diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx index 45801daddad8..107bf63acea4 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx @@ -45,7 +45,7 @@ export const SettingsDomain = () => { .regex( /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/, { - message: t`Invalid custom domain. Custom domains have to be smaller than 256 characters in length, cannot be IP addresses, cannot contain spaces, cannot contain any special characters such as _~\`!@#$%^*()=+{}[]|\\;:'",<>/? and cannot begin or end with a '-' character.`, + message: t`Invalid domain. Domains have to be smaller than 256 characters in length, cannot be IP addresses, cannot contain spaces, cannot contain any special characters such as _~\`!@#$%^*()=+{}[]|\\;:'",<>/? and cannot begin or end with a '-' character.`, }, ) .max(256) diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index 349b49097194..c3e24dbd7b91 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts @@ -50,6 +50,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: false, }, + { + key: FeatureFlagKey.IsApprovedAccessDomainsEnabled, + workspaceId: workspaceId, + value: true, + }, { key: FeatureFlagKey.IsBillingPlansEnabled, workspaceId: workspaceId, diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1740048555744-add-approved-access-domain.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1740048555744-add-approved-access-domain.ts new file mode 100644 index 000000000000..170a1c653ece --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1740048555744-add-approved-access-domain.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddApprovedAccessDomain1740048555744 + implements MigrationInterface +{ + name = 'AddApprovedAccessDomain1740048555744'; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `CREATE TABLE "core"."approvedAccessDomain" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "domain" character varying NOT NULL, "isValidated" boolean NOT NULL DEFAULT false, "workspaceId" uuid NOT NULL, CONSTRAINT "IndexOnDomainAndWorkspaceId" UNIQUE ("domain", "workspaceId"), CONSTRAINT "PK_523281ce57c84e1a039f4538c19" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "core"."approvedAccessDomain" ADD CONSTRAINT "FK_73d3e340b6ce0716a25a86361fc" 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"."approvedAccessDomain" DROP CONSTRAINT "FK_73d3e340b6ce0716a25a86361fc"`, + ); + await queryRunner.query(`DROP TABLE "core"."approvedAccessDomain"`); + } +} diff --git a/packages/twenty-server/src/database/typeorm/typeorm.service.ts b/packages/twenty-server/src/database/typeorm/typeorm.service.ts index 3cf105e22f19..9c498c49712e 100644 --- a/packages/twenty-server/src/database/typeorm/typeorm.service.ts +++ b/packages/twenty-server/src/database/typeorm/typeorm.service.ts @@ -22,6 +22,7 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; @Injectable() export class TypeORMService implements OnModuleInit, OnModuleDestroy { private mainDataSource: DataSource; @@ -50,6 +51,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy { BillingEntitlement, PostgresCredentials, WorkspaceSSOIdentityProvider, + ApprovedAccessDomain, TwoFactorMethod, ], metadataTableName: '_typeorm_generated_columns_and_materialized_views', diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.entity.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.entity.ts new file mode 100644 index 000000000000..bfbab3a4b672 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.entity.ts @@ -0,0 +1,45 @@ +import { ObjectType } from '@nestjs/graphql'; + +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, + Unique, +} from 'typeorm'; + +import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +@Entity({ name: 'approvedAccessDomain', schema: 'core' }) +@ObjectType() +@Unique('IndexOnDomainAndWorkspaceId', ['domain', 'workspaceId']) +export class ApprovedAccessDomain { + @PrimaryGeneratedColumn('uuid') + id: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; + + @Column({ type: 'varchar', nullable: false }) + domain: string; + + @Column({ type: 'boolean', default: false, nullable: false }) + isValidated: boolean; + + @Column() + workspaceId: string; + + @ManyToOne(() => Workspace, (workspace) => workspace.approvedAccessDomains, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'workspaceId' }) + workspace: Relation<Workspace>; +} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.exception.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.exception.ts new file mode 100644 index 000000000000..433407aa7ebd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.exception.ts @@ -0,0 +1,15 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class ApprovedAccessDomainException extends CustomException { + constructor(message: string, code: ApprovedAccessDomainExceptionCode) { + super(message, code); + } +} + +export enum ApprovedAccessDomainExceptionCode { + APPROVED_ACCESS_DOMAIN_NOT_FOUND = 'APPROVED_ACCESS_DOMAIN_NOT_FOUND', + APPROVED_ACCESS_DOMAIN_ALREADY_VERIFIED = 'APPROVED_ACCESS_DOMAIN_ALREADY_VERIFIED', + APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL = 'APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL', + APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID = 'APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID', + APPROVED_ACCESS_DOMAIN_ALREADY_VALIDATED = 'APPROVED_ACCESS_DOMAIN_ALREADY_VALIDATED', +} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.module.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.module.ts new file mode 100644 index 000000000000..ee7654fede3d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; + +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; + +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; +import { ApprovedAccessDomainResolver } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.resolver'; +import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; + +@Module({ + imports: [ + DomainManagerModule, + NestjsQueryTypeOrmModule.forFeature([ApprovedAccessDomain], 'core'), + ], + exports: [ApprovedAccessDomainService], + providers: [ApprovedAccessDomainService, ApprovedAccessDomainResolver], +}) +export class ApprovedAccessDomainModule {} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts new file mode 100644 index 000000000000..17bad8786e1a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts @@ -0,0 +1,71 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver, Query } from '@nestjs/graphql'; + +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 { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/dtos/approved-access-domain.dto'; +import { CreateApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input'; +import { DeleteApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/delete-approved-access-domain.input'; +import { ValidateApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/validate-approved-access-domain.input'; + +@UseGuards(WorkspaceAuthGuard) +@Resolver() +export class ApprovedAccessDomainResolver { + constructor( + private readonly approvedAccessDomainService: ApprovedAccessDomainService, + ) {} + + @Mutation(() => ApprovedAccessDomain) + async createApprovedAccessDomain( + @Args('input') { domain, email }: CreateApprovedAccessDomainInput, + @AuthWorkspace() currentWorkspace: Workspace, + @AuthUser() currentUser: User, + ): Promise<ApprovedAccessDomain> { + return this.approvedAccessDomainService.createApprovedAccessDomain( + domain, + currentWorkspace, + currentUser, + email, + ); + } + + @Mutation(() => Boolean) + async deleteApprovedAccessDomain( + @Args('input') { id }: DeleteApprovedAccessDomainInput, + @AuthWorkspace() currentWorkspace: Workspace, + ): Promise<boolean> { + await this.approvedAccessDomainService.deleteApprovedAccessDomain( + currentWorkspace, + id, + ); + + return true; + } + + @Mutation(() => ApprovedAccessDomain) + async validateApprovedAccessDomain( + @Args('input') + { + validationToken, + approvedAccessDomainId, + }: ValidateApprovedAccessDomainInput, + ): Promise<ApprovedAccessDomain> { + return await this.approvedAccessDomainService.validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId, + }); + } + + @Query(() => [ApprovedAccessDomain]) + async getApprovedAccessDomains( + @AuthWorkspace() currentWorkspace: Workspace, + ): Promise<Array<ApprovedAccessDomain>> { + return await this.approvedAccessDomainService.getApprovedAccessDomains( + currentWorkspace, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.validate.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.validate.ts new file mode 100644 index 000000000000..8d038f619bae --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.validate.ts @@ -0,0 +1,26 @@ +import { isDefined } from 'twenty-shared'; + +import { CustomException } from 'src/utils/custom-exception'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; +import { + ApprovedAccessDomainException, + ApprovedAccessDomainExceptionCode, +} from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception'; + +const assertIsDefinedOrThrow = ( + approvedAccessDomain: ApprovedAccessDomain | undefined | null, + exceptionToThrow: CustomException = new ApprovedAccessDomainException( + 'Approved access domain not found', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_NOT_FOUND, + ), +): asserts approvedAccessDomain is ApprovedAccessDomain => { + if (!isDefined(approvedAccessDomain)) { + throw exceptionToThrow; + } +}; + +export const approvedAccessDomainValidator: { + assertIsDefinedOrThrow: typeof assertIsDefinedOrThrow; +} = { + assertIsDefinedOrThrow, +}; diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/approved-access-domain.dto.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/approved-access-domain.dto.ts new file mode 100644 index 000000000000..3ac698369621 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/approved-access-domain.dto.ts @@ -0,0 +1,20 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IDField } from '@ptc-org/nestjs-query-graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ObjectType('ApprovedAccessDomain') +export class ApprovedAccessDomain { + @IDField(() => UUIDScalarType) + id: string; + + @Field({ nullable: false }) + domain: string; + + @Field({ nullable: false }) + isValidated: boolean; + + @Field() + createdAt: Date; +} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input.ts new file mode 100644 index 000000000000..abd26c50bba7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input.ts @@ -0,0 +1,16 @@ +import { InputType, Field } from '@nestjs/graphql'; + +import { IsString, IsEmail, IsNotEmpty } from 'class-validator'; + +@InputType() +export class CreateApprovedAccessDomainInput { + @Field(() => String) + @IsString() + @IsNotEmpty() + domain: string; + + @Field(() => String) + @IsEmail() + @IsNotEmpty() + email: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/delete-approved-access-domain.input.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/delete-approved-access-domain.input.ts new file mode 100644 index 000000000000..586c3acd34a0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/delete-approved-access-domain.input.ts @@ -0,0 +1,10 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsString } from 'class-validator'; + +@InputType() +export class DeleteApprovedAccessDomainInput { + @Field() + @IsString() + id: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/validate-approved-access-domain.input.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/validate-approved-access-domain.input.ts new file mode 100644 index 000000000000..0ef691b1a129 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/validate-approved-access-domain.input.ts @@ -0,0 +1,16 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsString, IsNotEmpty } from 'class-validator'; + +@InputType() +export class ValidateApprovedAccessDomainInput { + @Field(() => String) + @IsString() + @IsNotEmpty() + validationToken: string; + + @Field(() => String) + @IsString() + @IsNotEmpty() + approvedAccessDomainId: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts new file mode 100644 index 000000000000..6fb6de59e793 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts @@ -0,0 +1,184 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import crypto from 'crypto'; + +import { render } from '@react-email/render'; +import { Repository } from 'typeorm'; +import { APP_LOCALES } from 'twenty-shared'; +import { SendApprovedAccessDomainValidation } from 'twenty-emails'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { ApprovedAccessDomain as ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; +import { approvedAccessDomainValidator } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.validate'; +import { + ApprovedAccessDomainException, + ApprovedAccessDomainExceptionCode, +} from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception'; + +@Injectable() +// eslint-disable-next-line @nx/workspace-inject-workspace-repository +export class ApprovedAccessDomainService { + constructor( + @InjectRepository(ApprovedAccessDomainEntity, 'core') + private readonly approvedAccessDomainRepository: Repository<ApprovedAccessDomainEntity>, + private readonly emailService: EmailService, + private readonly environmentService: EnvironmentService, + private readonly domainManagerService: DomainManagerService, + ) {} + + async sendApprovedAccessDomainValidationEmail( + sender: User, + to: string, + workspace: Workspace, + approvedAccessDomain: ApprovedAccessDomainEntity, + ) { + if (approvedAccessDomain.isValidated) { + throw new ApprovedAccessDomainException( + 'Approved access domain has already been validated', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_VERIFIED, + ); + } + + if (to.split('@')[1] !== approvedAccessDomain.domain) { + throw new ApprovedAccessDomainException( + 'Approved access domain does not match email domain', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL, + ); + } + + const link = this.domainManagerService.buildWorkspaceURL({ + workspace, + pathname: `settings/security`, + searchParams: { + wtdId: approvedAccessDomain.id, + validationToken: this.generateUniqueHash(approvedAccessDomain), + }, + }); + + const emailTemplate = SendApprovedAccessDomainValidation({ + link: link.toString(), + workspace: { name: workspace.displayName, logo: workspace.logo }, + domain: approvedAccessDomain.domain, + sender: { + email: sender.email, + firstName: sender.firstName, + lastName: sender.lastName, + }, + serverUrl: this.environmentService.get('SERVER_URL'), + locale: 'en' as keyof typeof APP_LOCALES, + }); + const html = render(emailTemplate); + const text = render(emailTemplate, { + plainText: true, + }); + + await this.emailService.send({ + from: `${sender.firstName} ${sender.lastName} (via Twenty) <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`, + to, + subject: 'Approve your access domain', + text, + html, + }); + } + + private generateUniqueHash(approvedAccessDomain: ApprovedAccessDomainEntity) { + return crypto + .createHash('sha256') + .update( + JSON.stringify({ + id: approvedAccessDomain.id, + domain: approvedAccessDomain.domain, + key: this.environmentService.get('APP_SECRET'), + }), + ) + .digest('hex'); + } + + async validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId, + }: { + validationToken: string; + approvedAccessDomainId: string; + }) { + const approvedAccessDomain = + await this.approvedAccessDomainRepository.findOneBy({ + id: approvedAccessDomainId, + }); + + approvedAccessDomainValidator.assertIsDefinedOrThrow(approvedAccessDomain); + + if (approvedAccessDomain.isValidated) { + throw new ApprovedAccessDomainException( + 'Approved access domain has already been validated', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_VALIDATED, + ); + } + + const isHashValid = + this.generateUniqueHash(approvedAccessDomain) === validationToken; + + if (!isHashValid) { + throw new ApprovedAccessDomainException( + 'Invalid approved access domain validation token', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID, + ); + } + + return await this.approvedAccessDomainRepository.save({ + ...approvedAccessDomain, + isValidated: true, + }); + } + + async createApprovedAccessDomain( + domain: string, + inWorkspace: Workspace, + fromUser: User, + emailToValidateDomain: string, + ): Promise<ApprovedAccessDomainEntity> { + const approvedAccessDomain = await this.approvedAccessDomainRepository.save( + { + workspaceId: inWorkspace.id, + domain, + }, + ); + + await this.sendApprovedAccessDomainValidationEmail( + fromUser, + emailToValidateDomain, + inWorkspace, + approvedAccessDomain, + ); + + return approvedAccessDomain; + } + + async deleteApprovedAccessDomain( + workspace: Workspace, + approvedAccessDomainId: string, + ) { + const approvedAccessDomain = + await this.approvedAccessDomainRepository.findOneBy({ + id: approvedAccessDomainId, + workspaceId: workspace.id, + }); + + approvedAccessDomainValidator.assertIsDefinedOrThrow(approvedAccessDomain); + + await this.approvedAccessDomainRepository.delete(approvedAccessDomain); + } + + async getApprovedAccessDomains(workspace: Workspace) { + return await this.approvedAccessDomainRepository.find({ + where: { + workspaceId: workspace.id, + }, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts new file mode 100644 index 000000000000..bc506c63b41c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts @@ -0,0 +1,390 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { DeleteResult, Repository } from 'typeorm'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; +import { + ApprovedAccessDomainException, + ApprovedAccessDomainExceptionCode, +} from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception'; + +import { ApprovedAccessDomainService } from './approved-access-domain.service'; + +describe('ApprovedAccessDomainService', () => { + let service: ApprovedAccessDomainService; + let approvedAccessDomainRepository: Repository<ApprovedAccessDomain>; + let emailService: EmailService; + let environmentService: EnvironmentService; + let domainManagerService: DomainManagerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApprovedAccessDomainService, + { + provide: getRepositoryToken(ApprovedAccessDomain, 'core'), + useValue: { + delete: jest.fn(), + findOneBy: jest.fn(), + find: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: EmailService, + useValue: { + send: jest.fn(), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: DomainManagerService, + useValue: { + buildWorkspaceURL: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get<ApprovedAccessDomainService>( + ApprovedAccessDomainService, + ); + approvedAccessDomainRepository = module.get( + getRepositoryToken(ApprovedAccessDomain, 'core'), + ); + emailService = module.get<EmailService>(EmailService); + environmentService = module.get<EnvironmentService>(EnvironmentService); + domainManagerService = + module.get<DomainManagerService>(DomainManagerService); + }); + + describe('createApprovedAccessDomain', () => { + it('should successfully create an approved access domain', async () => { + const domain = 'custom-domain.com'; + const inWorkspace = { + id: 'workspace-id', + customDomain: null, + isCustomDomainEnabled: false, + } as Workspace; + const fromUser = { + email: 'user@custom-domain.com', + isEmailVerified: true, + } as User; + + const expectedApprovedAccessDomain = { + workspaceId: 'workspace-id', + domain, + isValidated: true, + }; + + jest + .spyOn(approvedAccessDomainRepository, 'save') + .mockResolvedValue( + expectedApprovedAccessDomain as unknown as ApprovedAccessDomain, + ); + + jest + .spyOn(service, 'sendApprovedAccessDomainValidationEmail') + .mockResolvedValue(); + + const result = await service.createApprovedAccessDomain( + domain, + inWorkspace, + fromUser, + 'validator@custom-domain.com', + ); + + expect(approvedAccessDomainRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-id', + domain, + }), + ); + expect(result).toEqual(expectedApprovedAccessDomain); + }); + }); + + describe('deleteApprovedAccessDomain', () => { + it('should delete an approved access domain successfully', async () => { + const workspace: Workspace = { id: 'workspace-id' } as Workspace; + const approvedAccessDomainId = 'approved-access-domain-id'; + const approvedAccessDomainEntity = { + id: approvedAccessDomainId, + workspaceId: workspace.id, + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomainEntity); + jest + .spyOn(approvedAccessDomainRepository, 'delete') + .mockResolvedValue({} as unknown as DeleteResult); + + await service.deleteApprovedAccessDomain( + workspace, + approvedAccessDomainId, + ); + + expect(approvedAccessDomainRepository.findOneBy).toHaveBeenCalledWith({ + id: approvedAccessDomainId, + workspaceId: workspace.id, + }); + expect(approvedAccessDomainRepository.delete).toHaveBeenCalledWith( + approvedAccessDomainEntity, + ); + }); + + it('should throw an error if the approved access domain does not exist', async () => { + const workspace: Workspace = { id: 'workspace-id' } as Workspace; + const approvedAccessDomainId = 'approved-access-domain-id'; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(null); + + await expect( + service.deleteApprovedAccessDomain(workspace, approvedAccessDomainId), + ).rejects.toThrow(); + + expect(approvedAccessDomainRepository.findOneBy).toHaveBeenCalledWith({ + id: approvedAccessDomainId, + workspaceId: workspace.id, + }); + expect(approvedAccessDomainRepository.delete).not.toHaveBeenCalled(); + }); + }); + + describe('sendApprovedAccessDomainValidationEmail', () => { + it('should throw an exception if the approved access domain is already validated', async () => { + const approvedAccessDomainId = 'approved-access-domain-id'; + const sender = {} as User; + const workspace = {} as Workspace; + const email = 'validator@example.com'; + + const approvedAccessDomain = { + id: approvedAccessDomainId, + isValidated: true, + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomain); + + await expect( + service.sendApprovedAccessDomainValidationEmail( + sender, + email, + workspace, + approvedAccessDomain, + ), + ).rejects.toThrowError( + new ApprovedAccessDomainException( + 'Approved access domain has already been validated', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_VERIFIED, + ), + ); + }); + + it('should throw an exception if the email does not match the approved access domain', async () => { + const approvedAccessDomainId = 'approved-access-domain-id'; + const sender = {} as User; + const workspace = {} as Workspace; + const email = 'validator@different.com'; + const approvedAccessDomain = { + id: approvedAccessDomainId, + isValidated: false, + domain: 'example.com', + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomain); + + await expect( + service.sendApprovedAccessDomainValidationEmail( + sender, + email, + workspace, + approvedAccessDomain, + ), + ).rejects.toThrowError( + new ApprovedAccessDomainException( + 'Approved access domain does not match email domain', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL, + ), + ); + }); + + it('should send a validation email if all conditions are met', async () => { + const sender = { + email: 'sender@example.com', + firstName: 'John', + lastName: 'Doe', + } as User; + const workspace = { + displayName: 'Test Workspace', + logo: '/logo.png', + } as Workspace; + const email = 'validator@custom-domain.com'; + const approvedAccessDomain = { + isValidated: false, + domain: 'custom-domain.com', + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomain); + + jest + .spyOn(domainManagerService, 'buildWorkspaceURL') + .mockReturnValue(new URL('https://sub.twenty.com')); + + jest + .spyOn(environmentService, 'get') + .mockImplementation((key: string) => { + if (key === 'EMAIL_FROM_ADDRESS') return 'no-reply@example.com'; + if (key === 'SERVER_URL') return 'https://api.example.com'; + }); + + await service.sendApprovedAccessDomainValidationEmail( + sender, + email, + workspace, + approvedAccessDomain, + ); + + expect(domainManagerService.buildWorkspaceURL).toHaveBeenCalledWith({ + workspace: workspace, + pathname: 'settings/security', + searchParams: { validationToken: expect.any(String) }, + }); + + expect(emailService.send).toHaveBeenCalledWith({ + from: 'John Doe (via Twenty) <no-reply@example.com>', + to: email, + subject: 'Approve your access domain', + text: expect.any(String), + html: expect.any(String), + }); + }); + }); + + describe('validateApprovedAccessDomain', () => { + it('should validate the approved access domain successfully with a correct token', async () => { + const approvedAccessDomainId = 'domain-id'; + const validationToken = 'valid-token'; + const approvedAccessDomain = { + id: approvedAccessDomainId, + domain: 'example.com', + isValidated: false, + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomain); + jest + .spyOn(service as any, 'generateUniqueHash') + .mockReturnValue(validationToken); + const saveSpy = jest.spyOn(approvedAccessDomainRepository, 'save'); + + await service.validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId: approvedAccessDomainId, + }); + + expect(approvedAccessDomainRepository.findOneBy).toHaveBeenCalledWith({ + id: approvedAccessDomainId, + }); + expect(saveSpy).toHaveBeenCalledWith( + expect.objectContaining({ isValidated: true }), + ); + }); + + it('should throw an error if the approved access domain does not exist', async () => { + const approvedAccessDomainId = 'invalid-domain-id'; + const validationToken = 'valid-token'; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(null); + + await expect( + service.validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId: approvedAccessDomainId, + }), + ).rejects.toThrowError( + new ApprovedAccessDomainException( + 'Approved access domain not found', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_NOT_FOUND, + ), + ); + }); + + it('should throw an error if the validation token is invalid', async () => { + const approvedAccessDomainId = 'domain-id'; + const validationToken = 'invalid-token'; + const approvedAccessDomain = { + id: approvedAccessDomainId, + domain: 'example.com', + isValidated: false, + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomain); + jest + .spyOn(service as any, 'generateUniqueHash') + .mockReturnValue('valid-token'); + + await expect( + service.validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId: approvedAccessDomainId, + }), + ).rejects.toThrowError( + new ApprovedAccessDomainException( + 'Invalid approved access domain validation token', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID, + ), + ); + }); + + it('should throw an error if the approved access domain is already validated', async () => { + const approvedAccessDomainId = 'domain-id'; + const validationToken = 'valid-token'; + const approvedAccessDomain = { + id: approvedAccessDomainId, + domain: 'example.com', + isValidated: true, + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomain); + + await expect( + service.validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId: approvedAccessDomainId, + }), + ).rejects.toThrowError( + new ApprovedAccessDomainException( + 'Approved access domain has already been validated', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_VALIDATED, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index 5bbc57e9c55b..ed10713e7b64 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -17,7 +17,7 @@ import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/micr // import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service'; import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; +import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; @@ -114,7 +114,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; ResetPasswordService, TransientTokenService, ApiKeyService, - SocialSsoService, + AuthSsoService, // reenable when working on: https://github.com/twentyhq/twenty/issues/9143 // OAuthService, ], diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/social-sso.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.service.ts similarity index 85% rename from packages/twenty-server/src/engine/core-modules/auth/services/social-sso.service.ts rename to packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.service.ts index 1d8287215633..30ef9996a598 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/social-sso.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.service.ts @@ -8,7 +8,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type'; @Injectable() -export class SocialSsoService { +export class AuthSsoService { constructor( @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository<Workspace>, @@ -55,14 +55,21 @@ export class SocialSsoService { }, }, }, - relations: ['workspaceUsers', 'workspaceUsers.user'], + relations: [ + 'workspaceUsers', + 'workspaceUsers.user', + 'approvedAccessDomains', + ], }); return workspace ?? undefined; } - return await this.workspaceRepository.findOneBy({ - id: workspaceId, + return await this.workspaceRepository.findOne({ + where: { + id: workspaceId, + }, + relations: ['approvedAccessDomains'], }); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/social-sso.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts similarity index 75% rename from packages/twenty-server/src/engine/core-modules/auth/services/social-sso.spec.ts rename to packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts index b03074726494..7f2ff2ecaf8b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/social-sso.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts @@ -3,22 +3,24 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; +import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -describe('SocialSsoService', () => { - let socialSsoService: SocialSsoService; +describe('AuthSsoService', () => { + let authSsoService: AuthSsoService; let workspaceRepository: Repository<Workspace>; let environmentService: EnvironmentService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - SocialSsoService, + AuthSsoService, { provide: getRepositoryToken(Workspace, 'core'), - useClass: Repository, + useValue: { + findOne: jest.fn(), + }, }, { provide: EnvironmentService, @@ -29,7 +31,7 @@ describe('SocialSsoService', () => { ], }).compile(); - socialSsoService = module.get<SocialSsoService>(SocialSsoService); + authSsoService = module.get<AuthSsoService>(AuthSsoService); workspaceRepository = module.get<Repository<Workspace>>( getRepositoryToken(Workspace, 'core'), ); @@ -42,18 +44,21 @@ describe('SocialSsoService', () => { const mockWorkspace = { id: workspaceId } as Workspace; jest - .spyOn(workspaceRepository, 'findOneBy') + .spyOn(workspaceRepository, 'findOne') .mockResolvedValue(mockWorkspace); const result = - await socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( + await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( { authProvider: 'google', email: 'test@example.com' }, workspaceId, ); expect(result).toEqual(mockWorkspace); - expect(workspaceRepository.findOneBy).toHaveBeenCalledWith({ - id: workspaceId, + expect(workspaceRepository.findOne).toHaveBeenCalledWith({ + where: { + id: workspaceId, + }, + relations: ['approvedAccessDomains'], }); }); @@ -68,7 +73,7 @@ describe('SocialSsoService', () => { .mockResolvedValue(mockWorkspace); const result = - await socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ + await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ authProvider, email, }); @@ -83,7 +88,11 @@ describe('SocialSsoService', () => { }, }, }, - relations: ['workspaceUsers', 'workspaceUsers.user'], + relations: [ + 'workspaceUsers', + 'workspaceUsers.user', + 'approvedAccessDomains', + ], }); }); @@ -92,7 +101,7 @@ describe('SocialSsoService', () => { jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null); const result = - await socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ + await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ authProvider: 'google', email: 'notfound@example.com', }); @@ -104,7 +113,7 @@ describe('SocialSsoService', () => { jest.spyOn(environmentService, 'get').mockReturnValue(true); await expect( - socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ + authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ authProvider: 'invalid-provider' as any, email: 'test@example.com', }), diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts index 6812aa94ace1..563b0ec3d27d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts @@ -10,7 +10,7 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; +import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { ExistingUserOrNewUser } from 'src/engine/core-modules/auth/types/signInUp.type'; @@ -28,7 +28,7 @@ import { AuthService } from './auth.service'; jest.mock('bcrypt'); const UserFindOneMock = jest.fn(); -const UserWorkspaceFindOneByMock = jest.fn(); +const UserWorkspacefindOneMock = jest.fn(); const userWorkspaceServiceCheckUserWorkspaceExistsMock = jest.fn(); const workspaceInvitationGetOneWorkspaceInvitationMock = jest.fn(); @@ -41,7 +41,7 @@ describe('AuthService', () => { let service: AuthService; let userService: UserService; let workspaceRepository: Repository<Workspace>; - let socialSsoService: SocialSsoService; + let authSsoService: AuthSsoService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -50,7 +50,7 @@ describe('AuthService', () => { { provide: getRepositoryToken(Workspace, 'core'), useValue: { - findOneBy: jest.fn(), + findOne: jest.fn(), }, }, { @@ -120,7 +120,7 @@ describe('AuthService', () => { }, }, { - provide: SocialSsoService, + provide: AuthSsoService, useValue: { findWorkspaceFromWorkspaceIdOrAuthProvider: jest.fn(), }, @@ -130,7 +130,7 @@ describe('AuthService', () => { service = module.get<AuthService>(AuthService); userService = module.get<UserService>(UserService); - socialSsoService = module.get<SocialSsoService>(SocialSsoService); + authSsoService = module.get<AuthSsoService>(AuthSsoService); workspaceRepository = module.get<Repository<Workspace>>( getRepositoryToken(Workspace, 'core'), ); @@ -160,7 +160,7 @@ describe('AuthService', () => { captchaToken: user.captchaToken, }); - UserWorkspaceFindOneByMock.mockReturnValueOnce({}); + UserWorkspacefindOneMock.mockReturnValueOnce({}); userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce({}); @@ -245,7 +245,8 @@ describe('AuthService', () => { workspace: { id: 'workspace-id', isPublicInviteLinkEnabled: true, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }); expect(spy).toHaveBeenCalledTimes(1); @@ -269,7 +270,8 @@ describe('AuthService', () => { workspace: { id: 'workspace-id', isPublicInviteLinkEnabled: true, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }), ).rejects.toThrow(new Error('Access denied')); @@ -292,7 +294,8 @@ describe('AuthService', () => { workspace: { id: 'workspace-id', isPublicInviteLinkEnabled: false, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }), ).rejects.toThrow( new AuthException( @@ -356,7 +359,7 @@ describe('AuthService', () => { } as ExistingUserOrNewUser['userData'], invitation: {} as AppToken, workspaceInviteHash: undefined, - workspace: {} as Workspace, + workspace: { approvedAccessDomains: [] } as unknown as Workspace, }); expect(spy).toHaveBeenCalledTimes(0); @@ -376,99 +379,127 @@ describe('AuthService', () => { workspaceInviteHash: 'workspaceInviteHash', workspace: { isPublicInviteLinkEnabled: true, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }); expect(spy).toHaveBeenCalledTimes(0); }); + + it('checkAccessForSignIn - allow signup for new user who target a workspace with valid trusted domain', async () => { + expect(async () => { + await service.checkAccessForSignIn({ + userData: { + type: 'newUser', + newUserPayload: { + email: 'email@domain.com', + }, + } as ExistingUserOrNewUser['userData'], + invitation: undefined, + workspaceInviteHash: 'workspaceInviteHash', + workspace: { + isPublicInviteLinkEnabled: true, + approvedAccessDomains: [ + { domain: 'domain.com', isValidated: true }, + ], + } as unknown as Workspace, + }); + }).not.toThrow(); + }); }); - it('findWorkspaceForSignInUp - signup password auth', async () => { - const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOneBy'); - const spySocialSsoService = jest.spyOn( - socialSsoService, - 'findWorkspaceFromWorkspaceIdOrAuthProvider', - ); + describe('findWorkspaceForSignInUp', () => { + it('findWorkspaceForSignInUp - signup password auth', async () => { + const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOne'); + const spyAuthSsoService = jest.spyOn( + authSsoService, + 'findWorkspaceFromWorkspaceIdOrAuthProvider', + ); + + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'password', + workspaceId: 'workspaceId', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'password', - workspaceId: 'workspaceId', + expect(result).toBeUndefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); + expect(spyAuthSsoService).toHaveBeenCalledTimes(0); }); + it('findWorkspaceForSignInUp - signup password auth with workspaceInviteHash', async () => { + const spyWorkspaceRepository = jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue({ + approvedAccessDomains: [], + } as unknown as Workspace); + const spyAuthSsoService = jest.spyOn( + authSsoService, + 'findWorkspaceFromWorkspaceIdOrAuthProvider', + ); - expect(result).toBeUndefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); - expect(spySocialSsoService).toHaveBeenCalledTimes(0); - }); - it('findWorkspaceForSignInUp - signup password auth with workspaceInviteHash', async () => { - const spyWorkspaceRepository = jest - .spyOn(workspaceRepository, 'findOneBy') - .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest.spyOn( - socialSsoService, - 'findWorkspaceFromWorkspaceIdOrAuthProvider', - ); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'password', + workspaceId: 'workspaceId', + workspaceInviteHash: 'workspaceInviteHash', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'password', - workspaceId: 'workspaceId', - workspaceInviteHash: 'workspaceInviteHash', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); + expect(spyAuthSsoService).toHaveBeenCalledTimes(0); }); + it('findWorkspaceForSignInUp - signup social sso auth with workspaceInviteHash', async () => { + const spyWorkspaceRepository = jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue({ + approvedAccessDomains: [], + } as unknown as Workspace); + const spyAuthSsoService = jest.spyOn( + authSsoService, + 'findWorkspaceFromWorkspaceIdOrAuthProvider', + ); - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); - expect(spySocialSsoService).toHaveBeenCalledTimes(0); - }); - it('findWorkspaceForSignInUp - signup social sso auth with workspaceInviteHash', async () => { - const spyWorkspaceRepository = jest - .spyOn(workspaceRepository, 'findOneBy') - .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest.spyOn( - socialSsoService, - 'findWorkspaceFromWorkspaceIdOrAuthProvider', - ); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'password', + workspaceId: 'workspaceId', + workspaceInviteHash: 'workspaceInviteHash', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'password', - workspaceId: 'workspaceId', - workspaceInviteHash: 'workspaceInviteHash', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); + expect(spyAuthSsoService).toHaveBeenCalledTimes(0); }); + it('findWorkspaceForSignInUp - signup social sso auth', async () => { + const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOne'); - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); - expect(spySocialSsoService).toHaveBeenCalledTimes(0); - }); - it('findWorkspaceForSignInUp - signup social sso auth', async () => { - const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOneBy'); + const spyAuthSsoService = jest + .spyOn(authSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') + .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest - .spyOn(socialSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') - .mockResolvedValue({} as Workspace); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'google', + workspaceId: 'workspaceId', + email: 'email', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'google', - workspaceId: 'workspaceId', - email: 'email', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); + expect(spyAuthSsoService).toHaveBeenCalledTimes(1); }); + it('findWorkspaceForSignInUp - sso auth', async () => { + const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOne'); - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); - expect(spySocialSsoService).toHaveBeenCalledTimes(1); - }); - it('findWorkspaceForSignInUp - sso auth', async () => { - const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOneBy'); + const spyAuthSsoService = jest + .spyOn(authSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') + .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest - .spyOn(socialSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') - .mockResolvedValue({} as Workspace); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'sso', + workspaceId: 'workspaceId', + email: 'email', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'sso', - workspaceId: 'workspaceId', - email: 'email', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); + expect(spyAuthSsoService).toHaveBeenCalledTimes(1); }); - - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); - expect(spySocialSsoService).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 202b44ed94c8..6268ca0c2b4b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -36,7 +36,7 @@ import { } from 'src/engine/core-modules/auth/dto/user-exists.entity'; import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; +import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { @@ -67,7 +67,7 @@ export class AuthService { private readonly refreshTokenService: RefreshTokenService, private readonly userWorkspaceService: UserWorkspaceService, private readonly workspaceInvitationService: WorkspaceInvitationService, - private readonly socialSsoService: SocialSsoService, + private readonly authSsoService: AuthSsoService, private readonly userService: UserService, private readonly signInUpService: SignInUpService, @InjectRepository(Workspace, 'core') @@ -518,15 +518,18 @@ export class AuthService { ) { if (params.workspaceInviteHash) { return ( - (await this.workspaceRepository.findOneBy({ - inviteHash: params.workspaceInviteHash, + (await this.workspaceRepository.findOne({ + where: { + inviteHash: params.workspaceInviteHash, + }, + relations: ['approvedAccessDomains'], })) ?? undefined ); } if (params.authProvider !== 'password') { return ( - (await this.socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( + (await this.authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( { email: params.email, authProvider: params.authProvider, @@ -568,6 +571,20 @@ export class AuthService { const isTargetAnExistingWorkspace = !!workspace; const isAnExistingUser = userData.type === 'existingUser'; + const email = + userData.type === 'newUser' + ? userData.newUserPayload.email + : userData.existingUser.email; + + if ( + workspace?.approvedAccessDomains.some( + (trustDomain) => + trustDomain.isValidated && trustDomain.domain === email.split('@')[1], + ) + ) { + return; + } + if ( hasPublicInviteLink && !hasPersonalInvitation && diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 80275bd85689..6926e75a317b 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -46,6 +46,7 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module'; +import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { ClientConfigModule } from './client-config/client-config.module'; @@ -68,6 +69,7 @@ import { FileModule } from './file/file.module'; WorkspaceModule, WorkspaceInvitationModule, WorkspaceSSOModule, + ApprovedAccessDomainModule, PostgresCredentialsModule, WorkflowApiModule, WorkspaceEventEmitterModule, diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 105d4da170fd..82e2a7e3e004 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -11,6 +11,7 @@ export enum FeatureFlagKey { IsCommandMenuV2Enabled = 'IS_COMMAND_MENU_V2_ENABLED', IsJsonFilterEnabled = 'IS_JSON_FILTER_ENABLED', IsCustomDomainEnabled = 'IS_CUSTOM_DOMAIN_ENABLED', + IsApprovedAccessDomainsEnabled = 'IS_APPROVED_ACCESS_DOMAINS_ENABLED', IsBillingPlansEnabled = 'IS_BILLING_PLANS_ENABLED', IsRichTextV2Enabled = 'IS_RICH_TEXT_V2_ENABLED', IsNewRelationEnabled = 'IS_NEW_RELATION_ENABLED', diff --git a/packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input.ts b/packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input.ts deleted file mode 100644 index 3cd5c91df79f..000000000000 --- a/packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* @license Enterprise */ - -import { Field, InputType } from '@nestjs/graphql'; - -import { IsEmail, IsNotEmpty } from 'class-validator'; - -@InputType() -export class FindAvailableSSOIDPInput { - @Field(() => String) - @IsNotEmpty() - @IsEmail() - email: string; -} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index ff3c2f705f3e..6a6b041b52e2 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -20,6 +20,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 { 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 { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; registerEnumType(WorkspaceActivationStatus, { name: 'WorkspaceActivationStatus', @@ -28,6 +29,7 @@ registerEnumType(WorkspaceActivationStatus, { @Entity({ name: 'workspace', schema: 'core' }) @ObjectType() export class Workspace { + // Fields @IDField(() => UUIDScalarType) @PrimaryGeneratedColumn('uuid') id: string; @@ -56,6 +58,15 @@ export class Workspace { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt: Date; + @Field() + @Column({ default: true }) + allowImpersonation: boolean; + + @Field() + @Column({ default: true }) + isPublicInviteLinkEnabled: boolean; + + // Relations @OneToMany(() => AppToken, (appToken) => appToken.workspace, { cascade: true, }) @@ -71,17 +82,15 @@ export class Workspace { }) workspaceUsers: Relation<UserWorkspace[]>; - @Field() - @Column({ default: true }) - allowImpersonation: boolean; - - @Field() - @Column({ default: true }) - isPublicInviteLinkEnabled: boolean; - @OneToMany(() => FeatureFlag, (featureFlag) => featureFlag.workspace) featureFlags: Relation<FeatureFlag[]>; + @OneToMany( + () => ApprovedAccessDomain, + (approvedAccessDomain) => approvedAccessDomain.workspace, + ) + approvedAccessDomains: Relation<ApprovedAccessDomain[]>; + @Field({ nullable: true }) workspaceMembersCount: number;