feat(twenty-server): add trusted domain - backend crud (#10290)

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
This commit is contained in:
Antoine Moreaux
2025-02-21 17:02:48 +01:00
committed by GitHub
parent 22203bfd3c
commit bf92860d19
49 changed files with 1812 additions and 147 deletions

View File

@ -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 (
<BaseEmail width={333} locale={locale}>
<Title value={t`Validate domain`} />
<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>
);
};

View File

@ -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';

View File

@ -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'];
@ -300,6 +308,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'];
@ -423,6 +436,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'];
@ -545,6 +562,7 @@ export enum FeatureFlagKey {
IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled',
IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled',
IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled',
IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled',
IsBillingPlansEnabled = 'IsBillingPlansEnabled',
IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled',
IsCopilotEnabled = 'IsCopilotEnabled',
@ -833,6 +851,7 @@ export type Mutation = {
checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>;
checkoutSession: BillingSessionOutput;
computeStepOutputSchema: Scalars['JSON']['output'];
createApprovedAccessDomain: ApprovedAccessDomain;
createDraftFromWorkflowVersion: WorkflowVersion;
createOIDCIdentityProvider: SetupSsoOutput;
createOneAppToken: AppToken;
@ -844,6 +863,7 @@ export type Mutation = {
createSAMLIdentityProvider: SetupSsoOutput;
createWorkflowVersionStep: WorkflowAction;
deactivateWorkflowVersion: Scalars['Boolean']['output'];
deleteApprovedAccessDomain: Scalars['Boolean']['output'];
deleteCurrentWorkspace: Workspace;
deleteOneField: Field;
deleteOneObject: Object;
@ -894,6 +914,7 @@ export type Mutation = {
uploadProfilePicture: Scalars['String']['output'];
uploadWorkspaceLogo: Scalars['String']['output'];
userLookupAdminPanel: UserLookup;
validateApprovedAccessDomain: ApprovedAccessDomain;
};
@ -927,6 +948,11 @@ export type MutationComputeStepOutputSchemaArgs = {
};
export type MutationCreateApprovedAccessDomainArgs = {
input: CreateApprovedAccessDomainInput;
};
export type MutationCreateDraftFromWorkflowVersionArgs = {
input: CreateDraftFromWorkflowVersionInput;
};
@ -982,6 +1008,11 @@ export type MutationDeactivateWorkflowVersionArgs = {
};
export type MutationDeleteApprovedAccessDomainArgs = {
input: DeleteApprovedAccessDomainInput;
};
export type MutationDeleteOneFieldArgs = {
input: DeleteOneFieldInput;
};
@ -1214,6 +1245,11 @@ export type MutationUserLookupAdminPanelArgs = {
userIdentifier: Scalars['String']['input'];
};
export type MutationValidateApprovedAccessDomainArgs = {
input: ValidateApprovedAccessDomainInput;
};
export type Object = {
__typename?: 'Object';
createdAt: Scalars['DateTime']['output'];
@ -1382,6 +1418,7 @@ export type Query = {
findOneServerlessFunction: ServerlessFunction;
findWorkspaceFromInviteHash: Workspace;
findWorkspaceInvitations: Array<WorkspaceInvitation>;
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
getAvailablePackages: Scalars['JSON']['output'];
getEnvironmentVariablesGrouped: EnvironmentVariablesOutput;
getIndicatorHealthStatus: AdminPanelHealthServiceData;
@ -2101,6 +2138,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'];

View File

@ -92,6 +92,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'];
@ -286,6 +294,11 @@ export type ComputeStepOutputSchemaInput = {
step: Scalars['JSON'];
};
export type CreateApprovedAccessDomainInput = {
domain: Scalars['String'];
email: Scalars['String'];
};
export type CreateDraftFromWorkflowVersionInput = {
/** Workflow ID */
workflowId: Scalars['String'];
@ -357,6 +370,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'];
@ -474,6 +491,7 @@ export enum FeatureFlagKey {
IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled',
IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled',
IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled',
IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled',
IsBillingPlansEnabled = 'IsBillingPlansEnabled',
IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled',
IsCopilotEnabled = 'IsCopilotEnabled',
@ -762,6 +780,7 @@ export type Mutation = {
checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>;
checkoutSession: BillingSessionOutput;
computeStepOutputSchema: Scalars['JSON'];
createApprovedAccessDomain: ApprovedAccessDomain;
createDraftFromWorkflowVersion: WorkflowVersion;
createOIDCIdentityProvider: SetupSsoOutput;
createOneAppToken: AppToken;
@ -771,6 +790,7 @@ export type Mutation = {
createSAMLIdentityProvider: SetupSsoOutput;
createWorkflowVersionStep: WorkflowAction;
deactivateWorkflowVersion: Scalars['Boolean'];
deleteApprovedAccessDomain: Scalars['Boolean'];
deleteCurrentWorkspace: Workspace;
deleteOneField: Field;
deleteOneObject: Object;
@ -815,6 +835,7 @@ export type Mutation = {
uploadProfilePicture: Scalars['String'];
uploadWorkspaceLogo: Scalars['String'];
userLookupAdminPanel: UserLookup;
validateApprovedAccessDomain: ApprovedAccessDomain;
};
@ -848,6 +869,11 @@ export type MutationComputeStepOutputSchemaArgs = {
};
export type MutationCreateApprovedAccessDomainArgs = {
input: CreateApprovedAccessDomainInput;
};
export type MutationCreateDraftFromWorkflowVersionArgs = {
input: CreateDraftFromWorkflowVersionInput;
};
@ -883,6 +909,11 @@ export type MutationDeactivateWorkflowVersionArgs = {
};
export type MutationDeleteApprovedAccessDomainArgs = {
input: DeleteApprovedAccessDomainInput;
};
export type MutationDeleteOneFieldArgs = {
input: DeleteOneFieldInput;
};
@ -1085,6 +1116,11 @@ export type MutationUserLookupAdminPanelArgs = {
userIdentifier: Scalars['String'];
};
export type MutationValidateApprovedAccessDomainArgs = {
input: ValidateApprovedAccessDomainInput;
};
export type Object = {
__typename?: 'Object';
createdAt: Scalars['DateTime'];
@ -1250,6 +1286,7 @@ export type Query = {
findOneServerlessFunction: ServerlessFunction;
findWorkspaceFromInviteHash: Workspace;
findWorkspaceInvitations: Array<WorkspaceInvitation>;
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
getAvailablePackages: Scalars['JSON'];
getEnvironmentVariablesGrouped: EnvironmentVariablesOutput;
getIndicatorHealthStatus: AdminPanelHealthServiceData;
@ -1887,6 +1924,11 @@ export type UserWorkspace = {
workspaceId: Scalars['String'];
};
export type ValidateApprovedAccessDomainInput = {
approvedAccessDomainId: Scalars['String'];
validationToken: Scalars['String'];
};
export type ValidatePasswordResetToken = {
__typename?: 'ValidatePasswordResetToken';
email: Scalars['String'];
@ -2333,6 +2375,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;
}>;
@ -2347,6 +2396,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;
}>;
@ -2361,6 +2417,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; }>;
@ -4230,6 +4298,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) {
@ -4304,6 +4408,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) {
@ -4374,6 +4509,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 {

View File

@ -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 />} />

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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();

View File

@ -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))
}
/>
</>
);
};

View File

@ -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>
}
/>
);
};

View File

@ -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 <></>;
};

View File

@ -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
}
}
`;

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const DELETE_APPROVED_ACCESS_DOMAIN = gql`
mutation DeleteApprovedAccessDomain(
$input: DeleteApprovedAccessDomainInput!
) {
deleteApprovedAccessDomain(input: $input)
}
`;

View File

@ -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
}
}
`;

View File

@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const GET_ALL_APPROVED_ACCESS_DOMAINS = gql`
query GetApprovedAccessDomains {
getApprovedAccessDomains {
id
createdAt
domain
isValidated
}
}
`;

View File

@ -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: [],
});

View File

@ -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',

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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

View File

@ -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)

View File

@ -50,6 +50,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: false,
},
{
key: FeatureFlagKey.IsApprovedAccessDomainsEnabled,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IsBillingPlansEnabled,
workspaceId: workspaceId,

View File

@ -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"`);
}
}

View File

@ -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',

View File

@ -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>;
}

View File

@ -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',
}

View File

@ -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 {}

View File

@ -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,
);
}
}

View File

@ -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,
};

View File

@ -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;
}

View File

@ -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;
}

View File

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

View File

@ -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;
}

View File

@ -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,
},
});
}
}

View File

@ -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,
),
);
});
});
});

View File

@ -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,
],

View File

@ -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'],
});
}
}

View File

@ -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',
}),

View File

@ -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);
});
});

View File

@ -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 &&

View File

@ -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,

View File

@ -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',

View File

@ -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;
}

View File

@ -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;