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:
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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';
|
||||
|
||||
@ -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'];
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
@ -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';
|
||||
@ -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';
|
||||
@ -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';
|
||||
@ -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';
|
||||
@ -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();
|
||||
@ -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))
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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 <></>;
|
||||
};
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const DELETE_APPROVED_ACCESS_DOMAIN = gql`
|
||||
mutation DeleteApprovedAccessDomain(
|
||||
$input: DeleteApprovedAccessDomainInput!
|
||||
) {
|
||||
deleteApprovedAccessDomain(input: $input)
|
||||
}
|
||||
`;
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,12 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_ALL_APPROVED_ACCESS_DOMAINS = gql`
|
||||
query GetApprovedAccessDomains {
|
||||
getApprovedAccessDomains {
|
||||
id
|
||||
createdAt
|
||||
domain
|
||||
isValidated
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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: [],
|
||||
});
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -50,6 +50,11 @@ export const seedFeatureFlags = async (
|
||||
workspaceId: workspaceId,
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IsApprovedAccessDomainsEnabled,
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IsBillingPlansEnabled,
|
||||
workspaceId: workspaceId,
|
||||
|
||||
@ -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"`);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
@ -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',
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class DeleteApprovedAccessDomainInput {
|
||||
@Field()
|
||||
@IsString()
|
||||
id: string;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
],
|
||||
|
||||
@ -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({
|
||||
return await this.workspaceRepository.findOne({
|
||||
where: {
|
||||
id: workspaceId,
|
||||
},
|
||||
relations: ['approvedAccessDomains'],
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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({
|
||||
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',
|
||||
}),
|
||||
@ -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,17 +379,40 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findWorkspaceForSignInUp', () => {
|
||||
it('findWorkspaceForSignInUp - signup password auth', async () => {
|
||||
const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOneBy');
|
||||
const spySocialSsoService = jest.spyOn(
|
||||
socialSsoService,
|
||||
const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOne');
|
||||
const spyAuthSsoService = jest.spyOn(
|
||||
authSsoService,
|
||||
'findWorkspaceFromWorkspaceIdOrAuthProvider',
|
||||
);
|
||||
|
||||
@ -397,14 +423,16 @@ describe('AuthService', () => {
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0);
|
||||
expect(spySocialSsoService).toHaveBeenCalledTimes(0);
|
||||
expect(spyAuthSsoService).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
it('findWorkspaceForSignInUp - signup password auth with workspaceInviteHash', async () => {
|
||||
const spyWorkspaceRepository = jest
|
||||
.spyOn(workspaceRepository, 'findOneBy')
|
||||
.mockResolvedValue({} as Workspace);
|
||||
const spySocialSsoService = jest.spyOn(
|
||||
socialSsoService,
|
||||
.spyOn(workspaceRepository, 'findOne')
|
||||
.mockResolvedValue({
|
||||
approvedAccessDomains: [],
|
||||
} as unknown as Workspace);
|
||||
const spyAuthSsoService = jest.spyOn(
|
||||
authSsoService,
|
||||
'findWorkspaceFromWorkspaceIdOrAuthProvider',
|
||||
);
|
||||
|
||||
@ -416,14 +444,16 @@ describe('AuthService', () => {
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1);
|
||||
expect(spySocialSsoService).toHaveBeenCalledTimes(0);
|
||||
expect(spyAuthSsoService).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,
|
||||
.spyOn(workspaceRepository, 'findOne')
|
||||
.mockResolvedValue({
|
||||
approvedAccessDomains: [],
|
||||
} as unknown as Workspace);
|
||||
const spyAuthSsoService = jest.spyOn(
|
||||
authSsoService,
|
||||
'findWorkspaceFromWorkspaceIdOrAuthProvider',
|
||||
);
|
||||
|
||||
@ -435,13 +465,13 @@ describe('AuthService', () => {
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1);
|
||||
expect(spySocialSsoService).toHaveBeenCalledTimes(0);
|
||||
expect(spyAuthSsoService).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
it('findWorkspaceForSignInUp - signup social sso auth', async () => {
|
||||
const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOneBy');
|
||||
const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOne');
|
||||
|
||||
const spySocialSsoService = jest
|
||||
.spyOn(socialSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider')
|
||||
const spyAuthSsoService = jest
|
||||
.spyOn(authSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider')
|
||||
.mockResolvedValue({} as Workspace);
|
||||
|
||||
const result = await service.findWorkspaceForSignInUp({
|
||||
@ -452,13 +482,13 @@ describe('AuthService', () => {
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0);
|
||||
expect(spySocialSsoService).toHaveBeenCalledTimes(1);
|
||||
expect(spyAuthSsoService).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('findWorkspaceForSignInUp - sso auth', async () => {
|
||||
const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOneBy');
|
||||
const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOne');
|
||||
|
||||
const spySocialSsoService = jest
|
||||
.spyOn(socialSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider')
|
||||
const spyAuthSsoService = jest
|
||||
.spyOn(authSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider')
|
||||
.mockResolvedValue({} as Workspace);
|
||||
|
||||
const result = await service.findWorkspaceForSignInUp({
|
||||
@ -469,6 +499,7 @@ describe('AuthService', () => {
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0);
|
||||
expect(spySocialSsoService).toHaveBeenCalledTimes(1);
|
||||
expect(spyAuthSsoService).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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({
|
||||
(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 &&
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user