From 3058936bae8d74d30b84fa865353675a7f524c44 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Tue, 18 Feb 2025 15:27:00 +0100 Subject: [PATCH 01/22] feat(settings/security): Add new trusted domain management Introduce functionality for managing trusted email domains in workspace settings. Includes UI components, validation schemas, GraphQL mutations, and queries to support adding, viewing, and deleting trusted domains. Also updates dependencies and adjusts existing components for better integration. --- package.json | 1 + .../modules/app/components/SettingsRoutes.tsx | 12 ++ ...tingsSecurityAuthProvidersOptionsList.tsx} | 2 +- .../SettingsTrustedDomainsListCard.tsx | 75 ++++++++++++ .../mutations/createWorkspaceTrustedDomain.ts | 14 +++ .../mutations/deleteWorkspaceTrustedDomain.ts | 7 ++ ...WorkspaceTrustedDomainVerificationEmail.ts | 9 ++ .../queries/getWorkspaceTrustedDomains.ts | 12 ++ .../validation-schemas/TrustedDomainSchema.ts | 8 ++ .../validation-schemas/domainSchema.ts | 13 ++ .../src/modules/types/SettingsPath.ts | 2 +- .../settings/security/SettingsSecurity.tsx | 17 ++- .../SettingsSecuritySSOIdentifyProvider.tsx | 7 +- .../SettingsSecurityTrustedDomain.tsx | 111 ++++++++++++++++++ .../settings/workspace/SettingsDomain.tsx | 13 +- .../workspace-trusted-domain.resolver.ts | 6 +- yarn.lock | 67 +++++++++++ 17 files changed, 352 insertions(+), 24 deletions(-) rename packages/twenty-front/src/modules/settings/security/components/{SettingsSecurityOptionsList.tsx => SettingsSecurityAuthProvidersOptionsList.tsx} (98%) create mode 100644 packages/twenty-front/src/modules/settings/security/components/SettingsTrustedDomainsListCard.tsx create mode 100644 packages/twenty-front/src/modules/settings/security/graphql/mutations/createWorkspaceTrustedDomain.ts create mode 100644 packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteWorkspaceTrustedDomain.ts create mode 100644 packages/twenty-front/src/modules/settings/security/graphql/mutations/sendWorkspaceTrustedDomainVerificationEmail.ts create mode 100644 packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceTrustedDomains.ts create mode 100644 packages/twenty-front/src/modules/settings/security/validation-schemas/TrustedDomainSchema.ts create mode 100644 packages/twenty-front/src/modules/settings/security/validation-schemas/domainSchema.ts create mode 100644 packages/twenty-front/src/pages/settings/security/SettingsSecurityTrustedDomain.tsx diff --git a/package.json b/package.json index 6514a0be36b4..c39ed8ac526e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@linaria/core": "^6.2.0", "@linaria/react": "^6.2.1", "@lingui/core": "^5.1.2", + "@lingui/macro": "^5.1.2", "@lingui/react": "^5.1.2", "@mdx-js/react": "^3.0.0", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index f2514b5ed220..47d7e22dfa24 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -231,6 +231,14 @@ const SettingsSecuritySSOIdentifyProvider = lazy(() => ), ); +const SettingsSecurityTrustedDomain = lazy(() => + import('~/pages/settings/security/SettingsSecurityTrustedDomain').then( + (module) => ({ + default: module.SettingsSecurityTrustedDomain, + }), + ), +); + const SettingsAdmin = lazy(() => import('~/pages/settings/admin-panel/SettingsAdmin').then((module) => ({ default: module.SettingsAdmin, @@ -386,6 +394,10 @@ export const SettingsRoutes = ({ path={SettingsPath.NewSSOIdentityProvider} element={} /> + } + /> {isAdminPageEnabled && ( <> } /> diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthProvidersOptionsList.tsx similarity index 98% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx rename to packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthProvidersOptionsList.tsx index ceb45356dab0..5684e081019a 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthProvidersOptionsList.tsx @@ -24,7 +24,7 @@ const StyledSettingsSecurityOptionsList = styled.div` gap: ${({ theme }) => theme.spacing(4)}; `; -export const SettingsSecurityOptionsList = () => { +export const SettingsSecurityAuthProvidersOptionsList = () => { const { t } = useLingui(); const { enqueueSnackBar } = useSnackBar(); diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsTrustedDomainsListCard.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsTrustedDomainsListCard.tsx new file mode 100644 index 000000000000..6c26c69c8141 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsTrustedDomainsListCard.tsx @@ -0,0 +1,75 @@ +import { Link, useNavigate } from 'react-router-dom'; + +import { SettingsPath } from '@/types/SettingsPath'; + +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { SettingsCard } from '@/settings/components/SettingsCard'; +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'; +import styled from '@emotion/styled'; +import { useLingui } from '@lingui/react/macro'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { IconMailCog } from 'twenty-ui'; +import { useListSsoIdentityProvidersByWorkspaceIdQuery } from '~/generated/graphql'; +import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; +import { SettingsListCard } from '@/settings/components/SettingsListCard'; +import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl'; +import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/security/components/SettingsSSOIdentityProviderRowRightContainer'; + +const StyledLink = styled(Link)` + text-decoration: none; +`; + +export const SettingsTrustedDomainsListCard = () => { + const { enqueueSnackBar } = useSnackBar(); + const navigate = useNavigate(); + + const currentWorkspace = useRecoilValue(currentWorkspaceState); + + const { t } = useLingui(); + + const [SSOIdentitiesProviders, setSSOIdentitiesProviders] = useRecoilState( + SSOIdentitiesProvidersState, + ); + + const { loading } = useListSsoIdentityProvidersByWorkspaceIdQuery({ + fetchPolicy: 'network-only', + skip: currentWorkspace?.hasValidEnterpriseKey === false, + onCompleted: (data) => { + setSSOIdentitiesProviders( + data?.listSSOIdentityProvidersByWorkspaceId ?? [], + ); + }, + onError: (error: Error) => { + enqueueSnackBar(error.message, { + variant: SnackBarVariant.Error, + }); + }, + }); + + return loading || !SSOIdentitiesProviders.length ? ( + + } + /> + + ) : ( + + `${SSOIdentityProvider.name} - ${SSOIdentityProvider.type}` + } + RowIconFn={(SSOIdentityProvider) => + guessSSOIdentityProviderIconByUrl(SSOIdentityProvider.issuer) + } + RowRightComponent={({ item: SSOIdp }) => ( + + )} + hasFooter + footerButtonLabel="Add SSO Identity Provider" + onFooterButtonClick={() => navigate(SettingsPath.NewSSOIdentityProvider)} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/createWorkspaceTrustedDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createWorkspaceTrustedDomain.ts new file mode 100644 index 000000000000..1bf03e22f5af --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createWorkspaceTrustedDomain.ts @@ -0,0 +1,14 @@ +/* @license Enterprise */ + +import { gql } from '@apollo/client'; + +export const CREATE_WORKSPACE_TRUSTED_DOMAIN = gql` + mutation CreateWorkspaceTrustDomain($input: CreateTrustedDomainInput!) { + createWorkspaceTrustedDomain(input: $input) { + id + domain + isValidated + createdAt + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteWorkspaceTrustedDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteWorkspaceTrustedDomain.ts new file mode 100644 index 000000000000..92788aedf298 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteWorkspaceTrustedDomain.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const DELETE_WORKSPACE_TRUSTED_DOMAIN = gql` + mutation DeleteWorkspaceTrustDomain($input: DeleteTrustedDomainInput!) { + deleteWorkspaceTrustDomain(input: $input) + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/sendWorkspaceTrustedDomainVerificationEmail.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/sendWorkspaceTrustedDomainVerificationEmail.ts new file mode 100644 index 000000000000..806cf92ca582 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/sendWorkspaceTrustedDomainVerificationEmail.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const SEND_TRUSTED_DOMAIN_VERIFICATION_EMAIL = gql` + mutation SendTrustedDomainVerificationEmail( + $input: SendTrustedDomainVerificationEmailInput! + ) { + sendTrustedDomainVerificationEmail(input: $input) + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceTrustedDomains.ts b/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceTrustedDomains.ts new file mode 100644 index 000000000000..70e7f09c181e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceTrustedDomains.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +export const LIST_WORKSPACE_TRUSTED_DOMAINS = gql` + query ListWorkspaceTrustedDomains { + listWorkspaceTrustedDomainsByWorkspaceId { + id + createdAt + domain + isValidated + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/validation-schemas/TrustedDomainSchema.ts b/packages/twenty-front/src/modules/settings/security/validation-schemas/TrustedDomainSchema.ts new file mode 100644 index 000000000000..edb2d98ae22a --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/validation-schemas/TrustedDomainSchema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const TrustedDomainParamsSchema = z + .object({ + domain: z.string().nonempty(), + email: z.string().url().nonempty(), + }) + .required(); diff --git a/packages/twenty-front/src/modules/settings/security/validation-schemas/domainSchema.ts b/packages/twenty-front/src/modules/settings/security/validation-schemas/domainSchema.ts new file mode 100644 index 000000000000..304bc2f800ed --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/validation-schemas/domainSchema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; +import { t } from '@lingui/macro'; + +export const domainSchema = 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])$/, + { + // eslint-disable-next-line lingui/t-call-in-function + 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.`, + }, + ) + .max(256); diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts index 30863792d7e4..68bda7b99869 100644 --- a/packages/twenty-front/src/modules/types/SettingsPath.ts +++ b/packages/twenty-front/src/modules/types/SettingsPath.ts @@ -29,7 +29,7 @@ export enum SettingsPath { IntegrationNewDatabaseConnection = 'integrations/:databaseKey/new', Security = 'security', NewSSOIdentityProvider = 'security/sso/new', - EditSSOIdentityProvider = 'security/sso/:identityProviderId', + NewTrustedDomain = 'security/trusted-domain/new', DevelopersNewWebhookDetail = 'developers/webhooks/:webhookId', Releases = 'releases', AdminPanel = 'admin-panel', diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx index 3e361abac1bd..55089cf4a51d 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -5,7 +5,7 @@ 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 { 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'; @@ -21,7 +21,7 @@ const StyledMainContent = styled.div` min-height: 200px; `; -const StyledSSOSection = styled(Section)` +const StyledSection = styled(Section)` flex-shrink: 0; `; @@ -42,7 +42,7 @@ export const SettingsSecurity = () => { > - + { } /> - + + + + {/**/} +
- +
diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx index d44b164a7c60..711f63e1a64c 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx @@ -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: Workspace, href: getSettingsPath(SettingsPath.Workspace), }, { - children: 'Security', + children: Security, href: getSettingsPath(SettingsPath.Security), }, - { children: 'New' }, + { children: New SSO provider }, ]} > { + const navigate = useNavigateSettings(); + + const { enqueueSnackBar } = useSnackBar(); + + const formConfig = useForm<{ domain: string; email: string }>({ + mode: 'onChange', + resolver: zodResolver( + z + .object({ + domain: domainSchema, + email: z.string().email(), + }) + .strict(), + ), + defaultValues: { + email: '', + domain: '', + }, + }); + + const handleSave = async () => { + try { + console.log('>>>>>>>>>>>>>> send ', formConfig.getValues()); + } catch (error) { + enqueueSnackBar((error as Error).message, { + variant: SnackBarVariant.Error, + }); + } + }; + + return ( + navigate(SettingsPath.Security)} + onSave={handleSave} + /> + } + links={[ + { + children: Workspace, + href: getSettingsPath(SettingsPath.Workspace), + }, + { + children: Security, + href: getSettingsPath(SettingsPath.Security), + }, + { children: New Trusted Domain }, + ]} + > +
+ + ( + + )} + /> +
+
+ + ( + + onChange( + `${str}@${formConfig.getValues('domain') ?? 'yourdomain.com'}`, + ) + } + fullWidth + /> + )} + /> +
+
+ ); +}; diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx index 45801daddad8..9c4f49fba87a 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx @@ -26,6 +26,7 @@ import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsCustomDomainEffect } from '~/pages/settings/workspace/SettingsCustomDomainEffect'; import { isDefined } from 'twenty-shared'; +import { domainSchema } from '@/settings/security/validation-schemas/domainSchema'; export const SettingsDomain = () => { const navigate = useNavigateSettings(); @@ -40,17 +41,7 @@ export const SettingsDomain = () => { .regex(/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/, { message: t`Use letter, number and dash only. Start and finish with a letter or a number`, }), - customDomain: 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 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.`, - }, - ) - .max(256) - .optional() - .or(z.literal('')), + customDomain: domainSchema.optional().or(z.literal('')), }) .required(); diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts index 0696d525025c..db7ecaeef2c4 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts @@ -20,7 +20,7 @@ export class WorkspaceTrustedDomainResolver { ) {} @Mutation(() => WorkspaceTrustedDomain) - async create( + async createWorkspaceTrustedDomain( @Args() { domain }: CreateTrustedDomainInput, @AuthWorkspace() currentWorkspace: Workspace, @AuthUser() currentUser: User, @@ -47,7 +47,7 @@ export class WorkspaceTrustedDomainResolver { } @Mutation(() => null) - async deleteTrustedDomain( + async deleteWorkspaceTrustedDomain( @Args() { id }: DeleteTrustedDomainInput, @AuthWorkspace() currentWorkspace: Workspace, ): Promise { @@ -58,7 +58,7 @@ export class WorkspaceTrustedDomainResolver { } @Mutation(() => [WorkspaceTrustedDomain]) - async getAllTrustedDomains( + async getAllWorkspaceTrustedDomains( @AuthWorkspace() currentWorkspace: Workspace, ): Promise> { return await this.workspaceTrustedDomainService.getAllTrustedDomainsByWorkspace( diff --git a/yarn.lock b/yarn.lock index 27367dac3e49..ebabda6b65b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7530,6 +7530,25 @@ __metadata: languageName: node linkType: hard +"@lingui/core@npm:5.2.0": + version: 5.2.0 + resolution: "@lingui/core@npm:5.2.0" + dependencies: + "@babel/runtime": "npm:^7.20.13" + "@lingui/message-utils": "npm:5.2.0" + unraw: "npm:^3.0.0" + peerDependencies: + "@lingui/babel-plugin-lingui-macro": 5.2.0 + babel-plugin-macros: 2 || 3 + peerDependenciesMeta: + "@lingui/babel-plugin-lingui-macro": + optional: true + babel-plugin-macros: + optional: true + checksum: 10c0/ca2d95c758b352eab44c2080485515cca7329d95ecbf700fe8bdc325b220eefe53ec8356e4f90ab638a31e2a466afa92e24c5644c04fb093e02048d2bf49ea38 + languageName: node + linkType: hard + "@lingui/core@npm:^5.1.2": version: 5.1.2 resolution: "@lingui/core@npm:5.1.2" @@ -7568,6 +7587,34 @@ __metadata: languageName: node linkType: hard +"@lingui/macro@npm:^5.1.2": + version: 5.2.0 + resolution: "@lingui/macro@npm:5.2.0" + dependencies: + "@lingui/core": "npm:5.2.0" + "@lingui/react": "npm:5.2.0" + peerDependencies: + "@lingui/babel-plugin-lingui-macro": 5.2.0 + babel-plugin-macros: 2 || 3 + peerDependenciesMeta: + "@lingui/babel-plugin-lingui-macro": + optional: true + babel-plugin-macros: + optional: true + checksum: 10c0/cad57d8b46e54f110e5442d21415be4eaca89993f779133b3f1d9eef9c0662594345346a1853bb2104cd00b80628eba6b7397ce1672e744576c5b0d9dd5951c0 + languageName: node + linkType: hard + +"@lingui/message-utils@npm:5.2.0": + version: 5.2.0 + resolution: "@lingui/message-utils@npm:5.2.0" + dependencies: + "@messageformat/parser": "npm:^5.0.0" + js-sha256: "npm:^0.10.1" + checksum: 10c0/eab1e817d1e8ff201ae1b17820c4d066ca8bf5e4fbf6b8626ba890b9309374410a2a598d067b68e3a7fbad728b86763b96fa5fb43495a273d1704a80ad8a88c8 + languageName: node + linkType: hard + "@lingui/message-utils@npm:^5.1.2": version: 5.1.2 resolution: "@lingui/message-utils@npm:5.1.2" @@ -7578,6 +7625,25 @@ __metadata: languageName: node linkType: hard +"@lingui/react@npm:5.2.0": + version: 5.2.0 + resolution: "@lingui/react@npm:5.2.0" + dependencies: + "@babel/runtime": "npm:^7.20.13" + "@lingui/core": "npm:5.2.0" + peerDependencies: + "@lingui/babel-plugin-lingui-macro": 5.2.0 + babel-plugin-macros: 2 || 3 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@lingui/babel-plugin-lingui-macro": + optional: true + babel-plugin-macros: + optional: true + checksum: 10c0/429fb6b262f9328366dda064220d725725fe3b527382e56d1e8789a26ede6ff16409361261204a9ab257f7ad0e5bb5ed031bb394a9b409468818437ceed4608a + languageName: node + linkType: hard + "@lingui/react@npm:^5.1.2": version: 5.1.2 resolution: "@lingui/react@npm:5.1.2" @@ -47115,6 +47181,7 @@ __metadata: "@linaria/react": "npm:^6.2.1" "@lingui/cli": "npm:^5.1.2" "@lingui/core": "npm:^5.1.2" + "@lingui/macro": "npm:^5.1.2" "@lingui/react": "npm:^5.1.2" "@lingui/swc-plugin": "npm:^5.1.0" "@lingui/vite-plugin": "npm:^5.1.2" From 2ea6345ca99aaa42ee328fd028b2f155ab0b545d Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Tue, 18 Feb 2025 18:07:32 +0100 Subject: [PATCH 02/22] feat(settings/security): Add new trusted domain management Introduce functionality for managing trusted email domains in workspace settings. Includes UI components, validation schemas, GraphQL mutations, and queries to support adding, viewing, and deleting trusted domains. Also updates dependencies and adjusts existing components for better integration. --- .../src/generated-metadata/graphql.ts | 57 ++++- .../twenty-front/src/generated/graphql.tsx | 201 ++++++++++++++++++ .../modules/app/components/SettingsRoutes.tsx | 5 + .../SettingsSSOIdentitiesProvidersForm.tsx | 4 +- ...SettingsSSOIdentitiesProvidersListCard.tsx | 2 +- ...sSSOIdentitiesProvidersListCardWrapper.tsx | 2 +- ...gsSSOIdentityProviderRowRightContainer.tsx | 2 +- .../{ => SSO}/SettingsSSOOIDCForm.tsx | 0 .../{ => SSO}/SettingsSSOSAMLForm.tsx | 0 .../SettingsSecuritySSORowDropdownMenu.tsx | 0 .../SettingsTrustedDomainsListCard.tsx | 75 ------- ...gsSecurityTrustedDomainRowDropdownMenu.tsx | 95 +++++++++ .../SettingsTrustedDomainsListCard.tsx | 70 ++++++ ...ettingsWorkspaceTrustedDomainSetupForm.tsx | 120 +++++++++++ ...gsWorkspaceTrustedDomainValidationForm.tsx | 163 ++++++++++++++ .../mutations/deleteWorkspaceTrustedDomain.ts | 2 +- .../queries/getWorkspaceTrustedDomains.ts | 6 +- .../states/WorkspaceTrustedDomainsState.ts | 9 + .../validation-schemas/TrustedDomainSchema.ts | 8 - .../src/modules/types/SettingsPath.ts | 3 + .../settings/security/SettingsSecurity.tsx | 5 +- .../SettingsSecuritySSOIdentifyProvider.tsx | 2 +- .../SettingsSecurityTrustedDomain.tsx | 162 ++++++++++---- .../engine/core-modules/core-engine.module.ts | 2 + .../sso/dtos/find-available-SSO-IDP.input.ts | 13 -- .../dtos/create-trusted-domain.input.ts | 2 +- ...trusted-domain-verification-email.input.ts | 2 +- .../workspace-trusted-domain.module.ts | 5 +- .../workspace-trusted-domain.resolver.ts | 27 ++- 29 files changed, 877 insertions(+), 167 deletions(-) rename packages/twenty-front/src/modules/settings/security/components/{ => SSO}/SettingsSSOIdentitiesProvidersForm.tsx (98%) rename packages/twenty-front/src/modules/settings/security/components/{ => SSO}/SettingsSSOIdentitiesProvidersListCard.tsx (96%) rename packages/twenty-front/src/modules/settings/security/components/{ => SSO}/SettingsSSOIdentitiesProvidersListCardWrapper.tsx (94%) rename packages/twenty-front/src/modules/settings/security/components/{ => SSO}/SettingsSSOIdentityProviderRowRightContainer.tsx (94%) rename packages/twenty-front/src/modules/settings/security/components/{ => SSO}/SettingsSSOOIDCForm.tsx (100%) rename packages/twenty-front/src/modules/settings/security/components/{ => SSO}/SettingsSSOSAMLForm.tsx (100%) rename packages/twenty-front/src/modules/settings/security/components/{ => SSO}/SettingsSecuritySSORowDropdownMenu.tsx (100%) delete mode 100644 packages/twenty-front/src/modules/settings/security/components/SettingsTrustedDomainsListCard.tsx create mode 100644 packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainRowDropdownMenu.tsx create mode 100644 packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsTrustedDomainsListCard.tsx create mode 100644 packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsWorkspaceTrustedDomainSetupForm.tsx create mode 100644 packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsWorkspaceTrustedDomainValidationForm.tsx create mode 100644 packages/twenty-front/src/modules/settings/security/states/WorkspaceTrustedDomainsState.ts delete mode 100644 packages/twenty-front/src/modules/settings/security/validation-schemas/TrustedDomainSchema.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 14e6a7aef402..fa1464dae244 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1,6 +1,5 @@ /* eslint-disable */ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; -import { PermissionsOnAllObjectRecords } from 'twenty-shared'; export type Maybe = T | null; export type InputMaybe = Maybe; export type Exact = { [K in keyof T]: T[K] }; @@ -257,6 +256,7 @@ export type ClientConfig = { debugMode: Scalars['Boolean']['output']; defaultSubdomain?: Maybe; frontDomain: Scalars['String']['output']; + isAttachmentPreviewEnabled: Scalars['Boolean']['output']; isEmailVerificationRequired: Scalars['Boolean']['output']; isGoogleCalendarEnabled: Scalars['Boolean']['output']; isGoogleMessagingEnabled: Scalars['Boolean']['output']; @@ -367,6 +367,10 @@ export type CreateServerlessFunctionInput = { timeoutSeconds?: InputMaybe; }; +export type CreateTrustedDomainInput = { + domain: Scalars['String']['input']; +}; + export type CreateWorkflowVersionStepInput = { /** New step type */ stepType: Scalars['String']['input']; @@ -425,6 +429,10 @@ export type DeleteSsoOutput = { identityProviderId: Scalars['String']['output']; }; +export type DeleteTrustedDomainInput = { + id: Scalars['String']['input']; +}; + export type DeleteWorkflowVersionStepInput = { /** Step to delete ID */ stepId: Scalars['String']['input']; @@ -809,6 +817,7 @@ export type Mutation = { activateWorkspace: Workspace; authorizeApp: AuthorizeApp; buildDraftServerlessFunction: ServerlessFunction; + checkCustomDomainValidRecords?: Maybe; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']['output']; createDraftFromWorkflowVersion: WorkflowVersion; @@ -821,6 +830,7 @@ export type Mutation = { createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; createWorkflowVersionStep: WorkflowAction; + createWorkspaceTrustedDomain: WorkspaceTrustedDomain; deactivateWorkflowVersion: Scalars['Boolean']['output']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; @@ -832,6 +842,7 @@ export type Mutation = { deleteUser: User; deleteWorkflowVersionStep: WorkflowAction; deleteWorkspaceInvitation: Scalars['String']['output']; + deleteWorkspaceTrustedDomain: Scalars['Boolean']['output']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; @@ -850,6 +861,7 @@ export type Mutation = { resendWorkspaceInvitation: SendInvitationsOutput; runWorkflowVersion: WorkflowRun; sendInvitations: SendInvitationsOutput; + sendTrustedDomainVerificationEmail: Scalars['Boolean']['output']; signUp: SignUpOutput; skipSyncEmailOnboardingStep: OnboardingStepSuccess; syncRemoteTable: RemoteTable; @@ -960,6 +972,11 @@ export type MutationCreateWorkflowVersionStepArgs = { }; +export type MutationCreateWorkspaceTrustedDomainArgs = { + input: CreateTrustedDomainInput; +}; + + export type MutationDeactivateWorkflowVersionArgs = { workflowVersionId: Scalars['String']['input']; }; @@ -1005,6 +1022,11 @@ export type MutationDeleteWorkspaceInvitationArgs = { }; +export type MutationDeleteWorkspaceTrustedDomainArgs = { + input: DeleteTrustedDomainInput; +}; + + export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; @@ -1085,6 +1107,11 @@ export type MutationSendInvitationsArgs = { }; +export type MutationSendTrustedDomainVerificationEmailArgs = { + input: SendTrustedDomainVerificationEmailInput; +}; + + export type MutationSignUpArgs = { captchaToken?: InputMaybe; email: Scalars['String']['input']; @@ -1305,6 +1332,13 @@ export type PageInfo = { startCursor?: Maybe; }; +export enum PermissionsOnAllObjectRecords { + DESTROY_ALL_OBJECT_RECORDS = 'DESTROY_ALL_OBJECT_RECORDS', + READ_ALL_OBJECT_RECORDS = 'READ_ALL_OBJECT_RECORDS', + SOFT_DELETE_ALL_OBJECT_RECORDS = 'SOFT_DELETE_ALL_OBJECT_RECORDS', + UPDATE_ALL_OBJECT_RECORDS = 'UPDATE_ALL_OBJECT_RECORDS' +} + export type PostgresCredentials = { __typename?: 'PostgresCredentials'; id: Scalars['UUID']['output']; @@ -1343,7 +1377,6 @@ export type PublishServerlessFunctionInput = { export type Query = { __typename?: 'Query'; billingPortalSession: BillingSessionOutput; - checkCustomDomainValidRecords?: Maybe; checkUserExists: UserExistsOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; clientConfig: ClientConfig; @@ -1359,6 +1392,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; + getAllWorkspaceTrustedDomains: Array; getAvailablePackages: Scalars['JSON']['output']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getPostgresCredentials?: Maybe; @@ -1633,6 +1667,10 @@ export type ResendEmailVerificationTokenOutput = { export type Role = { __typename?: 'Role'; + canDestroyAllObjectRecords: Scalars['Boolean']['output']; + canReadAllObjectRecords: Scalars['Boolean']['output']; + canSoftDeleteAllObjectRecords: Scalars['Boolean']['output']; + canUpdateAllObjectRecords: Scalars['Boolean']['output']; canUpdateAllSettings: Scalars['Boolean']['output']; description?: Maybe; id: Scalars['String']['output']; @@ -1680,6 +1718,11 @@ export type SendInvitationsOutput = { success: Scalars['Boolean']['output']; }; +export type SendTrustedDomainVerificationEmailInput = { + email: Scalars['String']['input']; + trustedDomainId: Scalars['String']['input']; +}; + export type Sentry = { __typename?: 'Sentry'; dsn?: Maybe; @@ -2050,8 +2093,8 @@ export type UserWorkspace = { createdAt: Scalars['DateTime']['output']; deletedAt?: Maybe; id: Scalars['UUID']['output']; - settingsPermissions?: Maybe>; objectRecordsPermissions?: Maybe>; + settingsPermissions?: Maybe>; updatedAt: Scalars['DateTime']['output']; user: User; userId: Scalars['String']['output']; @@ -2188,6 +2231,14 @@ export type WorkspaceNameAndId = { id: Scalars['String']['output']; }; +export type WorkspaceTrustedDomain = { + __typename?: 'WorkspaceTrustedDomain'; + createdAt: Scalars['DateTime']['output']; + domain: Scalars['String']['output']; + id: Scalars['UUID']['output']; + isValidated: Scalars['Boolean']['output']; +}; + export type WorkspaceUrlsAndId = { __typename?: 'WorkspaceUrlsAndId'; id: Scalars['String']['output']; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index cb2c72dae0f0..224c418b465e 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -304,6 +304,10 @@ export type CreateServerlessFunctionInput = { timeoutSeconds?: InputMaybe; }; +export type CreateTrustedDomainInput = { + domain: Scalars['String']; +}; + export type CreateWorkflowVersionStepInput = { /** New step type */ stepType: Scalars['String']; @@ -357,6 +361,10 @@ export type DeleteSsoOutput = { identityProviderId: Scalars['String']; }; +export type DeleteTrustedDomainInput = { + id: Scalars['String']; +}; + export type DeleteWorkflowVersionStepInput = { /** Step to delete ID */ stepId: Scalars['String']; @@ -745,6 +753,7 @@ export type Mutation = { createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; createWorkflowVersionStep: WorkflowAction; + createWorkspaceTrustedDomain: WorkspaceTrustedDomain; deactivateWorkflowVersion: Scalars['Boolean']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; @@ -754,6 +763,7 @@ export type Mutation = { deleteUser: User; deleteWorkflowVersionStep: WorkflowAction; deleteWorkspaceInvitation: Scalars['String']; + deleteWorkspaceTrustedDomain: Scalars['Boolean']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; @@ -772,6 +782,7 @@ export type Mutation = { resendWorkspaceInvitation: SendInvitationsOutput; runWorkflowVersion: WorkflowRun; sendInvitations: SendInvitationsOutput; + sendTrustedDomainVerificationEmail: Scalars['Boolean']; signUp: SignUpOutput; skipSyncEmailOnboardingStep: OnboardingStepSuccess; track: Analytics; @@ -858,6 +869,11 @@ export type MutationCreateWorkflowVersionStepArgs = { }; +export type MutationCreateWorkspaceTrustedDomainArgs = { + input: CreateTrustedDomainInput; +}; + + export type MutationDeactivateWorkflowVersionArgs = { workflowVersionId: Scalars['String']; }; @@ -893,6 +909,11 @@ export type MutationDeleteWorkspaceInvitationArgs = { }; +export type MutationDeleteWorkspaceTrustedDomainArgs = { + input: DeleteTrustedDomainInput; +}; + + export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; @@ -973,6 +994,11 @@ export type MutationSendInvitationsArgs = { }; +export type MutationSendTrustedDomainVerificationEmailArgs = { + input: SendTrustedDomainVerificationEmailInput; +}; + + export type MutationSignUpArgs = { captchaToken?: InputMaybe; email: Scalars['String']; @@ -1230,6 +1256,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; + getAllWorkspaceTrustedDomains: Array; getAvailablePackages: Scalars['JSON']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getPostgresCredentials?: Maybe; @@ -1487,6 +1514,11 @@ export type SendInvitationsOutput = { success: Scalars['Boolean']; }; +export type SendTrustedDomainVerificationEmailInput = { + email: Scalars['String']; + trustedDomainId: Scalars['String']; +}; + export type Sentry = { __typename?: 'Sentry'; dsn?: Maybe; @@ -1977,6 +2009,14 @@ export type WorkspaceNameAndId = { id: Scalars['String']; }; +export type WorkspaceTrustedDomain = { + __typename?: 'WorkspaceTrustedDomain'; + createdAt: Scalars['DateTime']; + domain: Scalars['String']; + id: Scalars['UUID']; + isValidated: Scalars['Boolean']; +}; + export type WorkspaceUrlsAndId = { __typename?: 'WorkspaceUrlsAndId'; id: Scalars['String']; @@ -2286,6 +2326,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 CreateWorkspaceTrustDomainMutationVariables = Exact<{ + input: CreateTrustedDomainInput; +}>; + + +export type CreateWorkspaceTrustDomainMutation = { __typename?: 'Mutation', createWorkspaceTrustedDomain: { __typename?: 'WorkspaceTrustedDomain', id: any, domain: string, isValidated: boolean, createdAt: string } }; + export type DeleteSsoIdentityProviderMutationVariables = Exact<{ input: DeleteSsoInput; }>; @@ -2293,6 +2340,13 @@ export type DeleteSsoIdentityProviderMutationVariables = Exact<{ export type DeleteSsoIdentityProviderMutation = { __typename?: 'Mutation', deleteSSOIdentityProvider: { __typename?: 'DeleteSsoOutput', identityProviderId: string } }; +export type DeleteWorkspaceTrustDomainMutationVariables = Exact<{ + input: DeleteTrustedDomainInput; +}>; + + +export type DeleteWorkspaceTrustDomainMutation = { __typename?: 'Mutation', deleteWorkspaceTrustedDomain: boolean }; + export type EditSsoIdentityProviderMutationVariables = Exact<{ input: EditSsoInput; }>; @@ -2300,11 +2354,23 @@ export type EditSsoIdentityProviderMutationVariables = Exact<{ export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; +export type SendTrustedDomainVerificationEmailMutationVariables = Exact<{ + input: SendTrustedDomainVerificationEmailInput; +}>; + + +export type SendTrustedDomainVerificationEmailMutation = { __typename?: 'Mutation', sendTrustedDomainVerificationEmail: boolean }; + export type ListSsoIdentityProvidersByWorkspaceIdQueryVariables = Exact<{ [key: string]: never; }>; export type ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> }; +export type GetAllWorkspaceTrustedDomainsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetAllWorkspaceTrustedDomainsQuery = { __typename?: 'Query', getAllWorkspaceTrustedDomains: Array<{ __typename?: 'WorkspaceTrustedDomain', id: any, createdAt: string, domain: string, isValidated: boolean }> }; + export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __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 } } | null, 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 } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> }; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; @@ -4148,6 +4214,42 @@ export function useCreateSamlIdentityProviderMutation(baseOptions?: Apollo.Mutat export type CreateSamlIdentityProviderMutationHookResult = ReturnType; export type CreateSamlIdentityProviderMutationResult = Apollo.MutationResult; export type CreateSamlIdentityProviderMutationOptions = Apollo.BaseMutationOptions; +export const CreateWorkspaceTrustDomainDocument = gql` + mutation CreateWorkspaceTrustDomain($input: CreateTrustedDomainInput!) { + createWorkspaceTrustedDomain(input: $input) { + id + domain + isValidated + createdAt + } +} + `; +export type CreateWorkspaceTrustDomainMutationFn = Apollo.MutationFunction; + +/** + * __useCreateWorkspaceTrustDomainMutation__ + * + * To run a mutation, you first call `useCreateWorkspaceTrustDomainMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateWorkspaceTrustDomainMutation` 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 [createWorkspaceTrustDomainMutation, { data, loading, error }] = useCreateWorkspaceTrustDomainMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateWorkspaceTrustDomainMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateWorkspaceTrustDomainDocument, options); + } +export type CreateWorkspaceTrustDomainMutationHookResult = ReturnType; +export type CreateWorkspaceTrustDomainMutationResult = Apollo.MutationResult; +export type CreateWorkspaceTrustDomainMutationOptions = Apollo.BaseMutationOptions; export const DeleteSsoIdentityProviderDocument = gql` mutation DeleteSSOIdentityProvider($input: DeleteSsoInput!) { deleteSSOIdentityProvider(input: $input) { @@ -4181,6 +4283,37 @@ export function useDeleteSsoIdentityProviderMutation(baseOptions?: Apollo.Mutati export type DeleteSsoIdentityProviderMutationHookResult = ReturnType; export type DeleteSsoIdentityProviderMutationResult = Apollo.MutationResult; export type DeleteSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions; +export const DeleteWorkspaceTrustDomainDocument = gql` + mutation DeleteWorkspaceTrustDomain($input: DeleteTrustedDomainInput!) { + deleteWorkspaceTrustedDomain(input: $input) +} + `; +export type DeleteWorkspaceTrustDomainMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteWorkspaceTrustDomainMutation__ + * + * To run a mutation, you first call `useDeleteWorkspaceTrustDomainMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteWorkspaceTrustDomainMutation` 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 [deleteWorkspaceTrustDomainMutation, { data, loading, error }] = useDeleteWorkspaceTrustDomainMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useDeleteWorkspaceTrustDomainMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteWorkspaceTrustDomainDocument, options); + } +export type DeleteWorkspaceTrustDomainMutationHookResult = ReturnType; +export type DeleteWorkspaceTrustDomainMutationResult = Apollo.MutationResult; +export type DeleteWorkspaceTrustDomainMutationOptions = Apollo.BaseMutationOptions; export const EditSsoIdentityProviderDocument = gql` mutation EditSSOIdentityProvider($input: EditSsoInput!) { editSSOIdentityProvider(input: $input) { @@ -4218,6 +4351,37 @@ export function useEditSsoIdentityProviderMutation(baseOptions?: Apollo.Mutation export type EditSsoIdentityProviderMutationHookResult = ReturnType; export type EditSsoIdentityProviderMutationResult = Apollo.MutationResult; export type EditSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions; +export const SendTrustedDomainVerificationEmailDocument = gql` + mutation SendTrustedDomainVerificationEmail($input: SendTrustedDomainVerificationEmailInput!) { + sendTrustedDomainVerificationEmail(input: $input) +} + `; +export type SendTrustedDomainVerificationEmailMutationFn = Apollo.MutationFunction; + +/** + * __useSendTrustedDomainVerificationEmailMutation__ + * + * To run a mutation, you first call `useSendTrustedDomainVerificationEmailMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useSendTrustedDomainVerificationEmailMutation` 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 [sendTrustedDomainVerificationEmailMutation, { data, loading, error }] = useSendTrustedDomainVerificationEmailMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useSendTrustedDomainVerificationEmailMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(SendTrustedDomainVerificationEmailDocument, options); + } +export type SendTrustedDomainVerificationEmailMutationHookResult = ReturnType; +export type SendTrustedDomainVerificationEmailMutationResult = Apollo.MutationResult; +export type SendTrustedDomainVerificationEmailMutationOptions = Apollo.BaseMutationOptions; export const ListSsoIdentityProvidersByWorkspaceIdDocument = gql` query ListSSOIdentityProvidersByWorkspaceId { listSSOIdentityProvidersByWorkspaceId { @@ -4256,6 +4420,43 @@ export function useListSsoIdentityProvidersByWorkspaceIdLazyQuery(baseOptions?: export type ListSsoIdentityProvidersByWorkspaceIdQueryHookResult = ReturnType; export type ListSsoIdentityProvidersByWorkspaceIdLazyQueryHookResult = ReturnType; export type ListSsoIdentityProvidersByWorkspaceIdQueryResult = Apollo.QueryResult; +export const GetAllWorkspaceTrustedDomainsDocument = gql` + query GetAllWorkspaceTrustedDomains { + getAllWorkspaceTrustedDomains { + id + createdAt + domain + isValidated + } +} + `; + +/** + * __useGetAllWorkspaceTrustedDomainsQuery__ + * + * To run a query within a React component, call `useGetAllWorkspaceTrustedDomainsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetAllWorkspaceTrustedDomainsQuery` 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 } = useGetAllWorkspaceTrustedDomainsQuery({ + * variables: { + * }, + * }); + */ +export function useGetAllWorkspaceTrustedDomainsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetAllWorkspaceTrustedDomainsDocument, options); + } +export function useGetAllWorkspaceTrustedDomainsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetAllWorkspaceTrustedDomainsDocument, options); + } +export type GetAllWorkspaceTrustedDomainsQueryHookResult = ReturnType; +export type GetAllWorkspaceTrustedDomainsLazyQueryHookResult = ReturnType; +export type GetAllWorkspaceTrustedDomainsQueryResult = Apollo.QueryResult; export const DeleteUserAccountDocument = gql` mutation DeleteUserAccount { deleteUser { diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 47d7e22dfa24..3e1833d9e9db 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -394,10 +394,15 @@ export const SettingsRoutes = ({ path={SettingsPath.NewSSOIdentityProvider} element={} /> + } + /> } /> + {isAdminPageEnabled && ( <> } /> diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersForm.tsx similarity index 98% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersForm.tsx index a54dd760e430..508dc6fbc2e2 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersForm.tsx @@ -2,8 +2,8 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsRadioCardContainer } from '@/settings/components/SettingsRadioCardContainer'; -import { SettingsSSOOIDCForm } from '@/settings/security/components/SettingsSSOOIDCForm'; -import { SettingsSSOSAMLForm } from '@/settings/security/components/SettingsSSOSAMLForm'; +import { SettingsSSOOIDCForm } from '@/settings/security/components/SSO/SettingsSSOOIDCForm'; +import { SettingsSSOSAMLForm } from '@/settings/security/components/SSO/SettingsSSOSAMLForm'; import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider'; import { TextInput } from '@/ui/input/components/TextInput'; import styled from '@emotion/styled'; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard.tsx similarity index 96% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard.tsx index 596742607eef..f45ed651619f 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard.tsx @@ -6,7 +6,7 @@ import { SettingsPath } from '@/types/SettingsPath'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { SettingsCard } from '@/settings/components/SettingsCard'; -import { SettingsSSOIdentitiesProvidersListCardWrapper } from '@/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper'; +import { SettingsSSOIdentitiesProvidersListCardWrapper } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCardWrapper'; import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCardWrapper.tsx similarity index 94% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCardWrapper.tsx index 5c2ee3ce8835..c6dddf82febe 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCardWrapper.tsx @@ -1,7 +1,7 @@ /* @license Enterprise */ import { SettingsListCard } from '@/settings/components/SettingsListCard'; -import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/security/components/SettingsSSOIdentityProviderRowRightContainer'; +import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/security/components/SSO/SettingsSSOIdentityProviderRowRightContainer'; import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState'; import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl'; import { SettingsPath } from '@/types/SettingsPath'; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentityProviderRowRightContainer.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentityProviderRowRightContainer.tsx similarity index 94% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentityProviderRowRightContainer.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentityProviderRowRightContainer.tsx index fb55040682c3..d90a72ebc8d2 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentityProviderRowRightContainer.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentityProviderRowRightContainer.tsx @@ -1,6 +1,6 @@ /* @license Enterprise */ -import { SettingsSecuritySSORowDropdownMenu } from '@/settings/security/components/SettingsSecuritySSORowDropdownMenu'; +import { SettingsSecuritySSORowDropdownMenu } from '@/settings/security/components/SSO/SettingsSecuritySSORowDropdownMenu'; import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState'; import { getColorBySSOIdentityProviderStatus } from '@/settings/security/utils/getColorBySSOIdentityProviderStatus'; import { Status } from 'twenty-ui'; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOOIDCForm.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx similarity index 100% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOOIDCForm.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOSAMLForm.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOSAMLForm.tsx similarity index 100% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOSAMLForm.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOSAMLForm.tsx diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecuritySSORowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSecuritySSORowDropdownMenu.tsx similarity index 100% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSecuritySSORowDropdownMenu.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSecuritySSORowDropdownMenu.tsx diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsTrustedDomainsListCard.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsTrustedDomainsListCard.tsx deleted file mode 100644 index 6c26c69c8141..000000000000 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsTrustedDomainsListCard.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Link, useNavigate } from 'react-router-dom'; - -import { SettingsPath } from '@/types/SettingsPath'; - -import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { SettingsCard } from '@/settings/components/SettingsCard'; -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'; -import styled from '@emotion/styled'; -import { useLingui } from '@lingui/react/macro'; -import { useRecoilState, useRecoilValue } from 'recoil'; -import { IconMailCog } from 'twenty-ui'; -import { useListSsoIdentityProvidersByWorkspaceIdQuery } from '~/generated/graphql'; -import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; -import { SettingsListCard } from '@/settings/components/SettingsListCard'; -import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl'; -import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/security/components/SettingsSSOIdentityProviderRowRightContainer'; - -const StyledLink = styled(Link)` - text-decoration: none; -`; - -export const SettingsTrustedDomainsListCard = () => { - const { enqueueSnackBar } = useSnackBar(); - const navigate = useNavigate(); - - const currentWorkspace = useRecoilValue(currentWorkspaceState); - - const { t } = useLingui(); - - const [SSOIdentitiesProviders, setSSOIdentitiesProviders] = useRecoilState( - SSOIdentitiesProvidersState, - ); - - const { loading } = useListSsoIdentityProvidersByWorkspaceIdQuery({ - fetchPolicy: 'network-only', - skip: currentWorkspace?.hasValidEnterpriseKey === false, - onCompleted: (data) => { - setSSOIdentitiesProviders( - data?.listSSOIdentityProvidersByWorkspaceId ?? [], - ); - }, - onError: (error: Error) => { - enqueueSnackBar(error.message, { - variant: SnackBarVariant.Error, - }); - }, - }); - - return loading || !SSOIdentitiesProviders.length ? ( - - } - /> - - ) : ( - - `${SSOIdentityProvider.name} - ${SSOIdentityProvider.type}` - } - RowIconFn={(SSOIdentityProvider) => - guessSSOIdentityProviderIconByUrl(SSOIdentityProvider.issuer) - } - RowRightComponent={({ item: SSOIdp }) => ( - - )} - hasFooter - footerButtonLabel="Add SSO Identity Provider" - onFooterButtonClick={() => navigate(SettingsPath.NewSSOIdentityProvider)} - /> - ); -}; diff --git a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainRowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainRowDropdownMenu.tsx new file mode 100644 index 000000000000..44526dc2731e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainRowDropdownMenu.tsx @@ -0,0 +1,95 @@ +import { + IconDotsVertical, + IconEditCircle, + 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 } from 'recoil'; +import { isDefined } from 'twenty-shared'; +import { useDeleteWorkspaceTrustDomainMutation } from '~/generated/graphql'; +import { workspaceTrustedDomainsState } from '@/settings/security/states/WorkspaceTrustedDomainsState'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +import { SettingsPath } from '@/types/SettingsPath'; + +type SettingsSecurityTrustedDomainRowDropdownMenuProps = { + workspaceTrustedDomain: UnwrapRecoilValue< + typeof workspaceTrustedDomainsState + >[0]; +}; + +export const SettingsSecurityTrustedDomainRowDropdownMenu = ({ + workspaceTrustedDomain, +}: SettingsSecurityTrustedDomainRowDropdownMenuProps) => { + const navigate = useNavigateSettings(); + + const dropdownId = `settings-account-row-${workspaceTrustedDomain.id}`; + + const { enqueueSnackBar } = useSnackBar(); + + const { closeDropdown } = useDropdown(dropdownId); + + const [deleteWorkspaceTrustDomain] = useDeleteWorkspaceTrustDomainMutation(); + + const handleDeleteWorkspaceTrustedDomain = async () => { + const result = await deleteWorkspaceTrustDomain({ + variables: { + input: { + id: workspaceTrustedDomain.id, + }, + }, + }); + if (isDefined(result.errors)) { + enqueueSnackBar('Error deleting workspace trust domain', { + variant: SnackBarVariant.Error, + duration: 2000, + }); + } + }; + + const handleEditWorkspaceTrustedDomain = () => { + navigate(SettingsPath.EditTrustedDomain, { + trustedDomainId: workspaceTrustedDomain.id, + }); + }; + + return ( + + } + dropdownMenuWidth={160} + dropdownComponents={ + + { + handleEditWorkspaceTrustedDomain(); + closeDropdown(); + }} + /> + { + handleDeleteWorkspaceTrustedDomain(); + closeDropdown(); + }} + /> + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsTrustedDomainsListCard.tsx b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsTrustedDomainsListCard.tsx new file mode 100644 index 000000000000..018eba9ea2b0 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsTrustedDomainsListCard.tsx @@ -0,0 +1,70 @@ +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 { useGetAllWorkspaceTrustedDomainsQuery } from '~/generated/graphql'; +import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; +import { SettingsListCard } from '@/settings/components/SettingsListCard'; +import { workspaceTrustedDomainsState } from '@/settings/security/states/WorkspaceTrustedDomainsState'; +import { SettingsSecurityTrustedDomainRowDropdownMenu } from '@/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainRowDropdownMenu'; + +const StyledLink = styled(Link)` + text-decoration: none; +`; + +export const SettingsTrustedDomainsListCard = () => { + const { enqueueSnackBar } = useSnackBar(); + const navigate = useNavigate(); + + const [workspaceTrustedDomains, setWorkspaceTrustedDomains] = useRecoilState( + workspaceTrustedDomainsState, + ); + + const { t } = useLingui(); + + const { loading } = useGetAllWorkspaceTrustedDomainsQuery({ + fetchPolicy: 'network-only', + onCompleted: (data) => { + setWorkspaceTrustedDomains(data?.getAllWorkspaceTrustedDomains ?? []); + }, + onError: (error: Error) => { + enqueueSnackBar(error.message, { + variant: SnackBarVariant.Error, + }); + }, + }); + + return loading || !workspaceTrustedDomains.length ? ( + + } + /> + + ) : ( + + `${workspaceTrustedDomain.domain} - ${workspaceTrustedDomain.createdAt}` + } + RowIcon={IconAt} + RowRightComponent={({ item: workspaceTrustedDomain }) => ( + + )} + hasFooter + footerButtonLabel="Add Trusted Domain" + onFooterButtonClick={() => + navigate(getSettingsPath(SettingsPath.NewTrustedDomain)) + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsWorkspaceTrustedDomainSetupForm.tsx b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsWorkspaceTrustedDomainSetupForm.tsx new file mode 100644 index 000000000000..c4f4c70a68fe --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsWorkspaceTrustedDomainSetupForm.tsx @@ -0,0 +1,120 @@ +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 { domainSchema } from '@/settings/security/validation-schemas/domainSchema'; +import { Trans } 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 { useCreateWorkspaceTrustDomainMutation } from '~/generated/graphql'; + +export const SettingsWorkspaceTrustedDomainSetupForm = () => { + const navigate = useNavigateSettings(); + + const { enqueueSnackBar } = useSnackBar(); + + const [createWorkspaceTrustDomain] = useCreateWorkspaceTrustDomainMutation(); + + const formConfig = useForm<{ domain: string; email: string }>({ + mode: 'onChange', + resolver: zodResolver( + z + .object({ + domain: domainSchema, + }) + .strict(), + ), + defaultValues: { + domain: '', + }, + }); + + const createWorkspaceTrustedDomainHandler = (domain: string) => { + createWorkspaceTrustDomain({ + variables: { + input: { + domain, + }, + }, + onCompleted: (workspaceTrustedDomain) => { + if (workspaceTrustedDomain.createWorkspaceTrustedDomain.isValidated) { + return navigate(SettingsPath.Security); + } + + setCurrentWorkspaceTrustedDomain( + workspaceTrustedDomain.createWorkspaceTrustedDomain, + ); + }, + onError: (error) => { + enqueueSnackBar((error as Error).message, { + variant: SnackBarVariant.Error, + }); + }, + }); + }; + + const handleSave = async () => { + try { + if (!currentWorkspaceTrustedDomain) { + return createWorkspaceTrustedDomainHandler( + formConfig.getValues('domain'), + ); + } + } catch (error) { + enqueueSnackBar((error as Error).message, { + variant: SnackBarVariant.Error, + }); + } + }; + + return ( + navigate(SettingsPath.Security)} + onSave={handleSave} + /> + } + links={[ + { + children: Workspace, + href: getSettingsPath(SettingsPath.Workspace), + }, + { + children: Security, + href: getSettingsPath(SettingsPath.Security), + }, + { children: New Trusted Domain }, + ]} + > + +
+ + ( + + )} + /> +
+
+
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsWorkspaceTrustedDomainValidationForm.tsx b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsWorkspaceTrustedDomainValidationForm.tsx new file mode 100644 index 000000000000..5af04284ac63 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsWorkspaceTrustedDomainValidationForm.tsx @@ -0,0 +1,163 @@ +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 { domainSchema } from '@/settings/security/validation-schemas/domainSchema'; +import { Trans } 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 { + useGetAllWorkspaceTrustedDomainsQuery, + useSendTrustedDomainVerificationEmailMutation, + WorkspaceTrustedDomain, +} from '~/generated/graphql'; +import { useState } from 'react'; +import { isDefined } from 'twenty-shared'; +import { useParams } from 'react-router-dom'; + +export const SettingsWorkspaceTrustedDomainValidationForm = () => { + const navigate = useNavigateSettings(); + + const { enqueueSnackBar } = useSnackBar(); + const params = useParams(); + + useGetAllWorkspaceTrustedDomainsQuery({ + onCompleted: (data) => { + if ( + isDefined(params.trustedDomainId) && + data.getAllWorkspaceTrustedDomains.length > 0 + ) { + const workspaceTrustedDomain = data.getAllWorkspaceTrustedDomains.find( + (workspaceTrustedDomain) => + workspaceTrustedDomain.id === params.trustedDomainId, + ); + if (isDefined(workspaceTrustedDomain)) { + setCurrentWorkspaceTrustedDomain(workspaceTrustedDomain); + formConfig.setValue('domain', workspaceTrustedDomain.domain); + } + } + }, + }); + + const [sendTrustedDomainVerificationEmail] = + useSendTrustedDomainVerificationEmailMutation(); + + const [currentWorkspaceTrustedDomain, setCurrentWorkspaceTrustedDomain] = + useState(); + + const formConfig = useForm<{ domain: string; email: string }>({ + mode: 'onChange', + resolver: zodResolver( + z + .object({ + domain: domainSchema, + email: z.string(), + }) + .strict(), + ), + defaultValues: { + email: '', + domain: '', + }, + }); + + const sendWorkspaceTrustedDomainVerificationEmailHandler = ( + workspaceTrustDomain: WorkspaceTrustedDomain, + ) => { + sendTrustedDomainVerificationEmail({ + variables: { + input: { + email: + formConfig.getValues('email') + '@' + workspaceTrustDomain.domain, + trustedDomainId: workspaceTrustDomain.id, + }, + }, + onCompleted: () => { + enqueueSnackBar('Email sent successfully', { + variant: SnackBarVariant.Success, + }); + }, + onError: (error) => { + enqueueSnackBar((error as Error).message, { + variant: SnackBarVariant.Error, + }); + }, + }); + }; + + const handleSend = async () => { + try { + if (!currentWorkspaceTrustedDomain) { + return createWorkspaceTrustedDomainHandler( + formConfig.getValues('domain'), + ); + } + + if ( + currentWorkspaceTrustedDomain && + !currentWorkspaceTrustedDomain.isValidated + ) { + return sendWorkspaceTrustedDomainVerificationEmailHandler( + currentWorkspaceTrustedDomain, + ); + } + } catch (error) { + enqueueSnackBar((error as Error).message, { + variant: SnackBarVariant.Error, + }); + } + }; + + return ( + navigate(SettingsPath.Security)} + onSave={handleSend} + /> + } + links={[ + { + children: Workspace, + href: getSettingsPath(SettingsPath.Workspace), + }, + { + children: Security, + href: getSettingsPath(SettingsPath.Security), + }, + { children: Validate Trusted Domain }, + ]} + > + +
+ + ( + + )} + /> +
+
+
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteWorkspaceTrustedDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteWorkspaceTrustedDomain.ts index 92788aedf298..9adba92bf86f 100644 --- a/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteWorkspaceTrustedDomain.ts +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteWorkspaceTrustedDomain.ts @@ -2,6 +2,6 @@ import { gql } from '@apollo/client'; export const DELETE_WORKSPACE_TRUSTED_DOMAIN = gql` mutation DeleteWorkspaceTrustDomain($input: DeleteTrustedDomainInput!) { - deleteWorkspaceTrustDomain(input: $input) + deleteWorkspaceTrustedDomain(input: $input) } `; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceTrustedDomains.ts b/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceTrustedDomains.ts index 70e7f09c181e..0278cf6867eb 100644 --- a/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceTrustedDomains.ts +++ b/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceTrustedDomains.ts @@ -1,8 +1,8 @@ import { gql } from '@apollo/client'; -export const LIST_WORKSPACE_TRUSTED_DOMAINS = gql` - query ListWorkspaceTrustedDomains { - listWorkspaceTrustedDomainsByWorkspaceId { +export const GET_ALL_WORKSPACE_TRUSTED_DOMAINS = gql` + query GetAllWorkspaceTrustedDomains { + getAllWorkspaceTrustedDomains { id createdAt domain diff --git a/packages/twenty-front/src/modules/settings/security/states/WorkspaceTrustedDomainsState.ts b/packages/twenty-front/src/modules/settings/security/states/WorkspaceTrustedDomainsState.ts new file mode 100644 index 000000000000..17b76027d931 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/states/WorkspaceTrustedDomainsState.ts @@ -0,0 +1,9 @@ +import { createState } from '@ui/utilities/state/utils/createState'; +import { WorkspaceTrustedDomain } from '~/generated/graphql'; + +export const workspaceTrustedDomainsState = createState< + Omit[] +>({ + key: 'WorkspaceTrustedDomainState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/settings/security/validation-schemas/TrustedDomainSchema.ts b/packages/twenty-front/src/modules/settings/security/validation-schemas/TrustedDomainSchema.ts deleted file mode 100644 index edb2d98ae22a..000000000000 --- a/packages/twenty-front/src/modules/settings/security/validation-schemas/TrustedDomainSchema.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from 'zod'; - -export const TrustedDomainParamsSchema = z - .object({ - domain: z.string().nonempty(), - email: z.string().url().nonempty(), - }) - .required(); diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts index 68bda7b99869..8f8c974cdcb0 100644 --- a/packages/twenty-front/src/modules/types/SettingsPath.ts +++ b/packages/twenty-front/src/modules/types/SettingsPath.ts @@ -30,6 +30,9 @@ export enum SettingsPath { Security = 'security', NewSSOIdentityProvider = 'security/sso/new', NewTrustedDomain = 'security/trusted-domain/new', + EditTrustedDomain = 'security/trusted-domain/edit/:trustedDomainId', + Webhooks = 'webhooks', + DevelopersNewWebhook = 'developers/webhooks/new', DevelopersNewWebhookDetail = 'developers/webhooks/:webhookId', Releases = 'releases', AdminPanel = 'admin-panel', diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx index 55089cf4a51d..80cdfa4d7317 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -4,11 +4,12 @@ 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 { 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 { SettingsTrustedDomainsListCard } from '@/settings/security/components/workspaceTrustedDomains/SettingsTrustedDomainsListCard'; const StyledContainer = styled.div` width: 100%; @@ -62,7 +63,7 @@ export const SettingsSecurity = () => { title={t`Approved Email Domain`} description={t`Anyone with an email address at these domains is allowed to sign up for this workspace.`} /> - {/**/} +
diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx index 711f63e1a64c..f54d5f557ce9 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx @@ -1,7 +1,7 @@ /* @license Enterprise */ import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; -import SettingsSSOIdentitiesProvidersForm from '@/settings/security/components/SettingsSSOIdentitiesProvidersForm'; +import SettingsSSOIdentitiesProvidersForm from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersForm'; import { useCreateSSOIdentityProvider } from '@/settings/security/hooks/useCreateSSOIdentityProvider'; import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider'; import { sSOIdentityProviderDefaultValues } from '@/settings/security/utils/sSOIdentityProviderDefaultValues'; diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurityTrustedDomain.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurityTrustedDomain.tsx index d161d66904b8..ca8f0db94ab6 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurityTrustedDomain.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurityTrustedDomain.tsx @@ -12,11 +12,28 @@ import { Trans } 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 { + useCreateWorkspaceTrustDomainMutation, + useSendTrustedDomainVerificationEmailMutation, + WorkspaceTrustedDomain, +} from '~/generated/graphql'; +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; export const SettingsSecurityTrustedDomain = () => { const navigate = useNavigateSettings(); const { enqueueSnackBar } = useSnackBar(); + const workspaceTrustedDomainIdFromParams = useParams().trustedDomainId; + + const [createWorkspaceTrustDomain] = useCreateWorkspaceTrustDomainMutation(); + + const [sendTrustedDomainVerificationEmail] = + useSendTrustedDomainVerificationEmailMutation(); + + const [currentWorkspaceTrustDomain, setCurrentWorkspaceTrustDomain] = + useState(); const formConfig = useForm<{ domain: string; email: string }>({ mode: 'onChange', @@ -24,7 +41,7 @@ export const SettingsSecurityTrustedDomain = () => { z .object({ domain: domainSchema, - email: z.string().email(), + email: z.string(), }) .strict(), ), @@ -34,9 +51,69 @@ export const SettingsSecurityTrustedDomain = () => { }, }); + const createWorkspaceTrustDomainHandler = (domain: string) => { + createWorkspaceTrustDomain({ + variables: { + input: { + domain, + }, + }, + onCompleted: (workspaceTrustDomain) => { + if (workspaceTrustDomain.createWorkspaceTrustedDomain.isValidated) { + return navigate(SettingsPath.Security); + } + + setCurrentWorkspaceTrustDomain( + workspaceTrustDomain.createWorkspaceTrustedDomain, + ); + }, + onError: (error) => { + enqueueSnackBar((error as Error).message, { + variant: SnackBarVariant.Error, + }); + }, + }); + }; + const sendWorkspaceTrustedDomainVerificationEmailHandler = ( + workspaceTrustDomain: WorkspaceTrustedDomain, + ) => { + sendTrustedDomainVerificationEmail({ + variables: { + input: { + email: + formConfig.getValues('email') + '@' + workspaceTrustDomain.domain, + trustedDomainId: workspaceTrustDomain.id, + }, + }, + onCompleted: () => { + enqueueSnackBar('Email sent successfully', { + variant: SnackBarVariant.Success, + }); + }, + onError: (error) => { + enqueueSnackBar((error as Error).message, { + variant: SnackBarVariant.Error, + }); + }, + }); + }; + const handleSave = async () => { try { - console.log('>>>>>>>>>>>>>> send ', formConfig.getValues()); + if (!currentWorkspaceTrustDomain) { + return createWorkspaceTrustDomainHandler( + formConfig.getValues('domain'), + ); + } + + if ( + currentWorkspaceTrustDomain && + !currentWorkspaceTrustDomain.isValidated + ) { + return sendWorkspaceTrustedDomainVerificationEmailHandler( + currentWorkspaceTrustDomain, + ); + } } catch (error) { enqueueSnackBar((error as Error).message, { variant: SnackBarVariant.Error, @@ -66,46 +143,47 @@ export const SettingsSecurityTrustedDomain = () => { { children: New Trusted Domain }, ]} > -
- - ( - - )} - /> -
-
- - ( - - onChange( - `${str}@${formConfig.getValues('domain') ?? 'yourdomain.com'}`, - ) - } - fullWidth - /> - )} - /> -
+ +
+ + ( + { + setCurrentWorkspaceTrustDomain(undefined); + onChange(domain); + }} + fullWidth + placeholder="yourdomain.com" + /> + )} + /> +
+
+ + ( + + )} + /> +
+
); }; diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 80275bd85689..74c21ae191a0 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -46,6 +46,7 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module'; +import { WorkspaceTrustedDomainModule } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-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, + WorkspaceTrustedDomainModule, PostgresCredentialsModule, WorkflowApiModule, WorkspaceEventEmitterModule, diff --git a/packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input.ts b/packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input.ts deleted file mode 100644 index 3cd5c91df79f..000000000000 --- a/packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* @license Enterprise */ - -import { Field, InputType } from '@nestjs/graphql'; - -import { IsEmail, IsNotEmpty } from 'class-validator'; - -@InputType() -export class FindAvailableSSOIDPInput { - @Field(() => String) - @IsNotEmpty() - @IsEmail() - email: string; -} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts index db2975076269..ce6677c9b51e 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts @@ -1,4 +1,4 @@ -import { Field, InputType } from '@nestjs/graphql'; +import { InputType, Field } from '@nestjs/graphql'; import { IsString } from 'class-validator'; diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/send-trusted-domain-verification-email.input.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/send-trusted-domain-verification-email.input.ts index 8a9c254f0a52..c0d280faaa8e 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/send-trusted-domain-verification-email.input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/send-trusted-domain-verification-email.input.ts @@ -1,4 +1,4 @@ -import { Field, InputType } from '@nestjs/graphql'; +import { InputType, Field } from '@nestjs/graphql'; import { IsString } from 'class-validator'; diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts index 8908349ed547..595bd16848d7 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts @@ -4,12 +4,15 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; import { WorkspaceTrustedDomainService } from 'src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service'; +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; +import { WorkspaceTrustedDomainResolver } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver'; @Module({ imports: [ + DomainManagerModule, NestjsQueryTypeOrmModule.forFeature([WorkspaceTrustedDomain], 'core'), ], exports: [WorkspaceTrustedDomainService], - providers: [WorkspaceTrustedDomainService], + providers: [WorkspaceTrustedDomainService, WorkspaceTrustedDomainResolver], }) export class WorkspaceTrustedDomainModule {} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts index db7ecaeef2c4..a9651d3662d5 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts @@ -1,5 +1,5 @@ import { UseGuards } from '@nestjs/common'; -import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { Args, Query, Mutation, Resolver } from '@nestjs/graphql'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; @@ -21,7 +21,7 @@ export class WorkspaceTrustedDomainResolver { @Mutation(() => WorkspaceTrustedDomain) async createWorkspaceTrustedDomain( - @Args() { domain }: CreateTrustedDomainInput, + @Args('input') { domain }: CreateTrustedDomainInput, @AuthWorkspace() currentWorkspace: Workspace, @AuthUser() currentUser: User, ): Promise { @@ -32,32 +32,37 @@ export class WorkspaceTrustedDomainResolver { ); } - @Mutation(() => null) + @Mutation(() => Boolean) async sendTrustedDomainVerificationEmail( - @Args() { email, trustedDomainId }: SendTrustedDomainVerificationEmailInput, + @Args('input') + { email, trustedDomainId }: SendTrustedDomainVerificationEmailInput, @AuthWorkspace() currentWorkspace: Workspace, @AuthUser() currentUser: User, - ): Promise { - return await this.workspaceTrustedDomainService.sendTrustedDomainValidationEmail( + ): Promise { + await this.workspaceTrustedDomainService.sendTrustedDomainValidationEmail( currentUser, email, currentWorkspace, trustedDomainId, ); + + return true; } - @Mutation(() => null) + @Mutation(() => Boolean) async deleteWorkspaceTrustedDomain( - @Args() { id }: DeleteTrustedDomainInput, + @Args('input') { id }: DeleteTrustedDomainInput, @AuthWorkspace() currentWorkspace: Workspace, - ): Promise { - return await this.workspaceTrustedDomainService.deleteTrustedDomain( + ): Promise { + await this.workspaceTrustedDomainService.deleteTrustedDomain( currentWorkspace, id, ); + + return true; } - @Mutation(() => [WorkspaceTrustedDomain]) + @Query(() => [WorkspaceTrustedDomain]) async getAllWorkspaceTrustedDomains( @AuthWorkspace() currentWorkspace: Workspace, ): Promise> { From 82c1f377ed43bee90589c736573abbe91c3b049e Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Wed, 19 Feb 2025 09:38:06 +0100 Subject: [PATCH 03/22] refactor(trusted domains): simplify trusted domain management Removed the concept of trusted domain email verification, streamlining the process for managing trusted domains. Adjusted the corresponding UI, server-side logic, and GraphQL types to reflect the updated approach, reducing complexity and unused functionality. --- .../src/generated-metadata/graphql.ts | 12 +- .../twenty-front/src/generated/graphql.tsx | 50 +----- ...gsSecurityTrustedDomainRowDropdownMenu.tsx | 33 ++-- ...ettingsWorkspaceTrustedDomainSetupForm.tsx | 120 ------------- ...gsWorkspaceTrustedDomainValidationForm.tsx | 163 ------------------ .../mutations/createWorkspaceTrustedDomain.ts | 2 - ...WorkspaceTrustedDomainVerificationEmail.ts | 9 - .../SettingsSecurityTrustedDomain.tsx | 106 ++++-------- .../workspace-trusted-domain.resolver.ts | 2 +- 9 files changed, 44 insertions(+), 453 deletions(-) delete mode 100644 packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsWorkspaceTrustedDomainSetupForm.tsx delete mode 100644 packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsWorkspaceTrustedDomainValidationForm.tsx delete mode 100644 packages/twenty-front/src/modules/settings/security/graphql/mutations/sendWorkspaceTrustedDomainVerificationEmail.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index fa1464dae244..78d6d1e694f8 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -369,6 +369,7 @@ export type CreateServerlessFunctionInput = { export type CreateTrustedDomainInput = { domain: Scalars['String']['input']; + email: Scalars['String']['input']; }; export type CreateWorkflowVersionStepInput = { @@ -861,7 +862,6 @@ export type Mutation = { resendWorkspaceInvitation: SendInvitationsOutput; runWorkflowVersion: WorkflowRun; sendInvitations: SendInvitationsOutput; - sendTrustedDomainVerificationEmail: Scalars['Boolean']['output']; signUp: SignUpOutput; skipSyncEmailOnboardingStep: OnboardingStepSuccess; syncRemoteTable: RemoteTable; @@ -1107,11 +1107,6 @@ export type MutationSendInvitationsArgs = { }; -export type MutationSendTrustedDomainVerificationEmailArgs = { - input: SendTrustedDomainVerificationEmailInput; -}; - - export type MutationSignUpArgs = { captchaToken?: InputMaybe; email: Scalars['String']['input']; @@ -1718,11 +1713,6 @@ export type SendInvitationsOutput = { success: Scalars['Boolean']['output']; }; -export type SendTrustedDomainVerificationEmailInput = { - email: Scalars['String']['input']; - trustedDomainId: Scalars['String']['input']; -}; - export type Sentry = { __typename?: 'Sentry'; dsn?: Maybe; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 224c418b465e..d640f195eeed 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -306,6 +306,7 @@ export type CreateServerlessFunctionInput = { export type CreateTrustedDomainInput = { domain: Scalars['String']; + email: Scalars['String']; }; export type CreateWorkflowVersionStepInput = { @@ -782,7 +783,6 @@ export type Mutation = { resendWorkspaceInvitation: SendInvitationsOutput; runWorkflowVersion: WorkflowRun; sendInvitations: SendInvitationsOutput; - sendTrustedDomainVerificationEmail: Scalars['Boolean']; signUp: SignUpOutput; skipSyncEmailOnboardingStep: OnboardingStepSuccess; track: Analytics; @@ -994,11 +994,6 @@ export type MutationSendInvitationsArgs = { }; -export type MutationSendTrustedDomainVerificationEmailArgs = { - input: SendTrustedDomainVerificationEmailInput; -}; - - export type MutationSignUpArgs = { captchaToken?: InputMaybe; email: Scalars['String']; @@ -1514,11 +1509,6 @@ export type SendInvitationsOutput = { success: Scalars['Boolean']; }; -export type SendTrustedDomainVerificationEmailInput = { - email: Scalars['String']; - trustedDomainId: Scalars['String']; -}; - export type Sentry = { __typename?: 'Sentry'; dsn?: Maybe; @@ -2354,13 +2344,6 @@ export type EditSsoIdentityProviderMutationVariables = Exact<{ export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; -export type SendTrustedDomainVerificationEmailMutationVariables = Exact<{ - input: SendTrustedDomainVerificationEmailInput; -}>; - - -export type SendTrustedDomainVerificationEmailMutation = { __typename?: 'Mutation', sendTrustedDomainVerificationEmail: boolean }; - export type ListSsoIdentityProvidersByWorkspaceIdQueryVariables = Exact<{ [key: string]: never; }>; @@ -4351,37 +4334,6 @@ export function useEditSsoIdentityProviderMutation(baseOptions?: Apollo.Mutation export type EditSsoIdentityProviderMutationHookResult = ReturnType; export type EditSsoIdentityProviderMutationResult = Apollo.MutationResult; export type EditSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions; -export const SendTrustedDomainVerificationEmailDocument = gql` - mutation SendTrustedDomainVerificationEmail($input: SendTrustedDomainVerificationEmailInput!) { - sendTrustedDomainVerificationEmail(input: $input) -} - `; -export type SendTrustedDomainVerificationEmailMutationFn = Apollo.MutationFunction; - -/** - * __useSendTrustedDomainVerificationEmailMutation__ - * - * To run a mutation, you first call `useSendTrustedDomainVerificationEmailMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useSendTrustedDomainVerificationEmailMutation` 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 [sendTrustedDomainVerificationEmailMutation, { data, loading, error }] = useSendTrustedDomainVerificationEmailMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function useSendTrustedDomainVerificationEmailMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(SendTrustedDomainVerificationEmailDocument, options); - } -export type SendTrustedDomainVerificationEmailMutationHookResult = ReturnType; -export type SendTrustedDomainVerificationEmailMutationResult = Apollo.MutationResult; -export type SendTrustedDomainVerificationEmailMutationOptions = Apollo.BaseMutationOptions; export const ListSsoIdentityProvidersByWorkspaceIdDocument = gql` query ListSSOIdentityProvidersByWorkspaceId { listSSOIdentityProvidersByWorkspaceId { diff --git a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainRowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainRowDropdownMenu.tsx index 44526dc2731e..9b758ec4df0d 100644 --- a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainRowDropdownMenu.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainRowDropdownMenu.tsx @@ -1,6 +1,5 @@ import { IconDotsVertical, - IconEditCircle, IconTrash, LightIconButton, MenuItem, @@ -11,12 +10,10 @@ 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 } from 'recoil'; +import { UnwrapRecoilValue, useSetRecoilState } from 'recoil'; import { isDefined } from 'twenty-shared'; import { useDeleteWorkspaceTrustDomainMutation } from '~/generated/graphql'; import { workspaceTrustedDomainsState } from '@/settings/security/states/WorkspaceTrustedDomainsState'; -import { useNavigateSettings } from '~/hooks/useNavigateSettings'; -import { SettingsPath } from '@/types/SettingsPath'; type SettingsSecurityTrustedDomainRowDropdownMenuProps = { workspaceTrustedDomain: UnwrapRecoilValue< @@ -27,10 +24,12 @@ type SettingsSecurityTrustedDomainRowDropdownMenuProps = { export const SettingsSecurityTrustedDomainRowDropdownMenu = ({ workspaceTrustedDomain, }: SettingsSecurityTrustedDomainRowDropdownMenuProps) => { - const navigate = useNavigateSettings(); - const dropdownId = `settings-account-row-${workspaceTrustedDomain.id}`; + const setWorkspaceTrustedDomains = useSetRecoilState( + workspaceTrustedDomainsState, + ); + const { enqueueSnackBar } = useSnackBar(); const { closeDropdown } = useDropdown(dropdownId); @@ -44,6 +43,13 @@ export const SettingsSecurityTrustedDomainRowDropdownMenu = ({ id: workspaceTrustedDomain.id, }, }, + onCompleted: () => { + setWorkspaceTrustedDomains((workspaceTrustedDomains) => { + return workspaceTrustedDomains.filter( + (trustedDomain) => trustedDomain.id !== workspaceTrustedDomain.id, + ); + }); + }, }); if (isDefined(result.errors)) { enqueueSnackBar('Error deleting workspace trust domain', { @@ -53,12 +59,6 @@ export const SettingsSecurityTrustedDomainRowDropdownMenu = ({ } }; - const handleEditWorkspaceTrustedDomain = () => { - navigate(SettingsPath.EditTrustedDomain, { - trustedDomainId: workspaceTrustedDomain.id, - }); - }; - return ( - { - handleEditWorkspaceTrustedDomain(); - closeDropdown(); - }} - /> { - const navigate = useNavigateSettings(); - - const { enqueueSnackBar } = useSnackBar(); - - const [createWorkspaceTrustDomain] = useCreateWorkspaceTrustDomainMutation(); - - const formConfig = useForm<{ domain: string; email: string }>({ - mode: 'onChange', - resolver: zodResolver( - z - .object({ - domain: domainSchema, - }) - .strict(), - ), - defaultValues: { - domain: '', - }, - }); - - const createWorkspaceTrustedDomainHandler = (domain: string) => { - createWorkspaceTrustDomain({ - variables: { - input: { - domain, - }, - }, - onCompleted: (workspaceTrustedDomain) => { - if (workspaceTrustedDomain.createWorkspaceTrustedDomain.isValidated) { - return navigate(SettingsPath.Security); - } - - setCurrentWorkspaceTrustedDomain( - workspaceTrustedDomain.createWorkspaceTrustedDomain, - ); - }, - onError: (error) => { - enqueueSnackBar((error as Error).message, { - variant: SnackBarVariant.Error, - }); - }, - }); - }; - - const handleSave = async () => { - try { - if (!currentWorkspaceTrustedDomain) { - return createWorkspaceTrustedDomainHandler( - formConfig.getValues('domain'), - ); - } - } catch (error) { - enqueueSnackBar((error as Error).message, { - variant: SnackBarVariant.Error, - }); - } - }; - - return ( - navigate(SettingsPath.Security)} - onSave={handleSave} - /> - } - links={[ - { - children: Workspace, - href: getSettingsPath(SettingsPath.Workspace), - }, - { - children: Security, - href: getSettingsPath(SettingsPath.Security), - }, - { children: New Trusted Domain }, - ]} - > - -
- - ( - - )} - /> -
-
-
- ); -}; diff --git a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsWorkspaceTrustedDomainValidationForm.tsx b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsWorkspaceTrustedDomainValidationForm.tsx deleted file mode 100644 index 5af04284ac63..000000000000 --- a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsWorkspaceTrustedDomainValidationForm.tsx +++ /dev/null @@ -1,163 +0,0 @@ -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 { domainSchema } from '@/settings/security/validation-schemas/domainSchema'; -import { Trans } 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 { - useGetAllWorkspaceTrustedDomainsQuery, - useSendTrustedDomainVerificationEmailMutation, - WorkspaceTrustedDomain, -} from '~/generated/graphql'; -import { useState } from 'react'; -import { isDefined } from 'twenty-shared'; -import { useParams } from 'react-router-dom'; - -export const SettingsWorkspaceTrustedDomainValidationForm = () => { - const navigate = useNavigateSettings(); - - const { enqueueSnackBar } = useSnackBar(); - const params = useParams(); - - useGetAllWorkspaceTrustedDomainsQuery({ - onCompleted: (data) => { - if ( - isDefined(params.trustedDomainId) && - data.getAllWorkspaceTrustedDomains.length > 0 - ) { - const workspaceTrustedDomain = data.getAllWorkspaceTrustedDomains.find( - (workspaceTrustedDomain) => - workspaceTrustedDomain.id === params.trustedDomainId, - ); - if (isDefined(workspaceTrustedDomain)) { - setCurrentWorkspaceTrustedDomain(workspaceTrustedDomain); - formConfig.setValue('domain', workspaceTrustedDomain.domain); - } - } - }, - }); - - const [sendTrustedDomainVerificationEmail] = - useSendTrustedDomainVerificationEmailMutation(); - - const [currentWorkspaceTrustedDomain, setCurrentWorkspaceTrustedDomain] = - useState(); - - const formConfig = useForm<{ domain: string; email: string }>({ - mode: 'onChange', - resolver: zodResolver( - z - .object({ - domain: domainSchema, - email: z.string(), - }) - .strict(), - ), - defaultValues: { - email: '', - domain: '', - }, - }); - - const sendWorkspaceTrustedDomainVerificationEmailHandler = ( - workspaceTrustDomain: WorkspaceTrustedDomain, - ) => { - sendTrustedDomainVerificationEmail({ - variables: { - input: { - email: - formConfig.getValues('email') + '@' + workspaceTrustDomain.domain, - trustedDomainId: workspaceTrustDomain.id, - }, - }, - onCompleted: () => { - enqueueSnackBar('Email sent successfully', { - variant: SnackBarVariant.Success, - }); - }, - onError: (error) => { - enqueueSnackBar((error as Error).message, { - variant: SnackBarVariant.Error, - }); - }, - }); - }; - - const handleSend = async () => { - try { - if (!currentWorkspaceTrustedDomain) { - return createWorkspaceTrustedDomainHandler( - formConfig.getValues('domain'), - ); - } - - if ( - currentWorkspaceTrustedDomain && - !currentWorkspaceTrustedDomain.isValidated - ) { - return sendWorkspaceTrustedDomainVerificationEmailHandler( - currentWorkspaceTrustedDomain, - ); - } - } catch (error) { - enqueueSnackBar((error as Error).message, { - variant: SnackBarVariant.Error, - }); - } - }; - - return ( - navigate(SettingsPath.Security)} - onSave={handleSend} - /> - } - links={[ - { - children: Workspace, - href: getSettingsPath(SettingsPath.Workspace), - }, - { - children: Security, - href: getSettingsPath(SettingsPath.Security), - }, - { children: Validate Trusted Domain }, - ]} - > - -
- - ( - - )} - /> -
-
-
- ); -}; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/createWorkspaceTrustedDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createWorkspaceTrustedDomain.ts index 1bf03e22f5af..75a0c87150e9 100644 --- a/packages/twenty-front/src/modules/settings/security/graphql/mutations/createWorkspaceTrustedDomain.ts +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createWorkspaceTrustedDomain.ts @@ -1,5 +1,3 @@ -/* @license Enterprise */ - import { gql } from '@apollo/client'; export const CREATE_WORKSPACE_TRUSTED_DOMAIN = gql` diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/sendWorkspaceTrustedDomainVerificationEmail.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/sendWorkspaceTrustedDomainVerificationEmail.ts deleted file mode 100644 index 806cf92ca582..000000000000 --- a/packages/twenty-front/src/modules/settings/security/graphql/mutations/sendWorkspaceTrustedDomainVerificationEmail.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { gql } from '@apollo/client'; - -export const SEND_TRUSTED_DOMAIN_VERIFICATION_EMAIL = gql` - mutation SendTrustedDomainVerificationEmail( - $input: SendTrustedDomainVerificationEmailInput! - ) { - sendTrustedDomainVerificationEmail(input: $input) - } -`; diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurityTrustedDomain.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurityTrustedDomain.tsx index ca8f0db94ab6..59ff77a42a79 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurityTrustedDomain.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurityTrustedDomain.tsx @@ -8,40 +8,29 @@ import { Controller, useForm } from 'react-hook-form'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; import { domainSchema } from '@/settings/security/validation-schemas/domainSchema'; -import { Trans } from '@lingui/react/macro'; +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 { - useCreateWorkspaceTrustDomainMutation, - useSendTrustedDomainVerificationEmailMutation, - WorkspaceTrustedDomain, -} from '~/generated/graphql'; -import { useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useCreateWorkspaceTrustDomainMutation } from '~/generated/graphql'; export const SettingsSecurityTrustedDomain = () => { const navigate = useNavigateSettings(); + const { t } = useLingui(); + const { enqueueSnackBar } = useSnackBar(); - const workspaceTrustedDomainIdFromParams = useParams().trustedDomainId; const [createWorkspaceTrustDomain] = useCreateWorkspaceTrustDomainMutation(); - const [sendTrustedDomainVerificationEmail] = - useSendTrustedDomainVerificationEmailMutation(); - - const [currentWorkspaceTrustDomain, setCurrentWorkspaceTrustDomain] = - useState(); - const formConfig = useForm<{ domain: string; email: string }>({ mode: 'onChange', resolver: zodResolver( z .object({ domain: domainSchema, - email: z.string(), + email: z.string().min(1), }) .strict(), ), @@ -51,69 +40,32 @@ export const SettingsSecurityTrustedDomain = () => { }, }); - const createWorkspaceTrustDomainHandler = (domain: string) => { - createWorkspaceTrustDomain({ - variables: { - input: { - domain, - }, - }, - onCompleted: (workspaceTrustDomain) => { - if (workspaceTrustDomain.createWorkspaceTrustedDomain.isValidated) { - return navigate(SettingsPath.Security); - } - - setCurrentWorkspaceTrustDomain( - workspaceTrustDomain.createWorkspaceTrustedDomain, - ); - }, - onError: (error) => { - enqueueSnackBar((error as Error).message, { - variant: SnackBarVariant.Error, - }); - }, - }); - }; - const sendWorkspaceTrustedDomainVerificationEmailHandler = ( - workspaceTrustDomain: WorkspaceTrustedDomain, - ) => { - sendTrustedDomainVerificationEmail({ - variables: { - input: { - email: - formConfig.getValues('email') + '@' + workspaceTrustDomain.domain, - trustedDomainId: workspaceTrustDomain.id, - }, - }, - onCompleted: () => { - enqueueSnackBar('Email sent successfully', { - variant: SnackBarVariant.Success, - }); - }, - onError: (error) => { - enqueueSnackBar((error as Error).message, { - variant: SnackBarVariant.Error, - }); - }, - }); - }; + const domain = formConfig.watch('domain'); const handleSave = async () => { try { - if (!currentWorkspaceTrustDomain) { - return createWorkspaceTrustDomainHandler( - formConfig.getValues('domain'), - ); - } - - if ( - currentWorkspaceTrustDomain && - !currentWorkspaceTrustDomain.isValidated - ) { - return sendWorkspaceTrustedDomainVerificationEmailHandler( - currentWorkspaceTrustDomain, - ); - } + createWorkspaceTrustDomain({ + 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, @@ -155,7 +107,6 @@ export const SettingsSecurityTrustedDomain = () => { label="Domain" value={value} onChange={(domain: string) => { - setCurrentWorkspaceTrustDomain(undefined); onChange(domain); }} fullWidth @@ -182,6 +133,7 @@ export const SettingsSecurityTrustedDomain = () => { /> )} /> + {domain}
diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts index f6d97bf5b24b..2aceb79da7b5 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts @@ -1,5 +1,5 @@ import { UseGuards } from '@nestjs/common'; -import { Args, Query, Mutation, Resolver, Query } from '@nestjs/graphql'; +import { Args, Query, Mutation, Resolver } from '@nestjs/graphql'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; From eae87d84def2e22d8a9ad3cf3712a306b77c8d91 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Wed, 19 Feb 2025 10:17:55 +0100 Subject: [PATCH 04/22] fix(workspace-trusted-domain): correct delete method parameter Changed the delete method to use trustedDomain.id instead of the entire trustedDomain object. This ensures the correct data is passed to the repository and avoids potential errors. --- .../services/workspace-trusted-domain.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts index f8bde62ba41b..af4dd05f54d2 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts @@ -161,7 +161,7 @@ export class WorkspaceTrustedDomainService { workspaceTrustedDomainValidator.assertIsDefinedOrThrow(trustedDomain); - await this.workspaceTrustedDomainRepository.delete(trustedDomain); + await this.workspaceTrustedDomainRepository.delete(trustedDomain.id); } async getAllTrustedDomainsByWorkspace(workspace: Workspace) { From 6a8b2048a5562318a48e09d3e7aad2e637879561 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Wed, 19 Feb 2025 15:46:01 +0100 Subject: [PATCH 05/22] feat(settings/security): add useValidateTrustedDomain hook Introduce a hook to validate trusted domains using a validation token. This sets up the foundation for handling domain validation in the security settings module. --- .../settings/security/hooks/useValidateTrustedDomain.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/twenty-front/src/modules/settings/security/hooks/useValidateTrustedDomain.ts diff --git a/packages/twenty-front/src/modules/settings/security/hooks/useValidateTrustedDomain.ts b/packages/twenty-front/src/modules/settings/security/hooks/useValidateTrustedDomain.ts new file mode 100644 index 000000000000..a95ebfa6594a --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/hooks/useValidateTrustedDomain.ts @@ -0,0 +1,7 @@ +export const useValidateTrustedDomain = () => { + const validateTrustedDomain = (validationToken: string) => {}; + + return { + validateTrustedDomain, + }; +}; From 39230e260e1b7272c2112492d0860d5a4a99d95f Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Wed, 19 Feb 2025 16:46:21 +0100 Subject: [PATCH 06/22] feat(security): Add trusted domain validation via mutation Introduced a mutation to validate trusted domains using Apollo Client and removed the previous hook-based implementation. Added a new component to handle validation side effects and integrated it into the trusted domains list. This enhances reliability and provides user feedback for validation success or failure. --- .../twenty-front/src/generated/graphql.tsx | 38 +++++++++++++++++ ...sSecurityTrustedDomainValidationEffect.tsx | 42 +++++++++++++++++++ .../SettingsTrustedDomainsListCard.tsx | 41 +++++++++--------- .../validateWorkspaceTrustedDomain.ts | 7 ++++ .../hooks/useValidateTrustedDomain.ts | 7 ---- 5 files changed, 109 insertions(+), 26 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainValidationEffect.tsx create mode 100644 packages/twenty-front/src/modules/settings/security/graphql/mutations/validateWorkspaceTrustedDomain.ts delete mode 100644 packages/twenty-front/src/modules/settings/security/hooks/useValidateTrustedDomain.ts diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 999a37c67e13..497be7d052e6 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -2419,6 +2419,13 @@ export type EditSsoIdentityProviderMutationVariables = Exact<{ export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; +export type ValidateWorkspaceTrustedDomainMutationVariables = Exact<{ + input: ValidateTrustedDomainInput; +}>; + + +export type ValidateWorkspaceTrustedDomainMutation = { __typename?: 'Mutation', validateWorkspaceTrustedDomain: boolean }; + export type ListSsoIdentityProvidersByWorkspaceIdQueryVariables = Exact<{ [key: string]: never; }>; @@ -4519,6 +4526,37 @@ export function useEditSsoIdentityProviderMutation(baseOptions?: Apollo.Mutation export type EditSsoIdentityProviderMutationHookResult = ReturnType; export type EditSsoIdentityProviderMutationResult = Apollo.MutationResult; export type EditSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions; +export const ValidateWorkspaceTrustedDomainDocument = gql` + mutation ValidateWorkspaceTrustedDomain($input: ValidateTrustedDomainInput!) { + validateWorkspaceTrustedDomain(input: $input) +} + `; +export type ValidateWorkspaceTrustedDomainMutationFn = Apollo.MutationFunction; + +/** + * __useValidateWorkspaceTrustedDomainMutation__ + * + * To run a mutation, you first call `useValidateWorkspaceTrustedDomainMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useValidateWorkspaceTrustedDomainMutation` 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 [validateWorkspaceTrustedDomainMutation, { data, loading, error }] = useValidateWorkspaceTrustedDomainMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useValidateWorkspaceTrustedDomainMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ValidateWorkspaceTrustedDomainDocument, options); + } +export type ValidateWorkspaceTrustedDomainMutationHookResult = ReturnType; +export type ValidateWorkspaceTrustedDomainMutationResult = Apollo.MutationResult; +export type ValidateWorkspaceTrustedDomainMutationOptions = Apollo.BaseMutationOptions; export const ListSsoIdentityProvidersByWorkspaceIdDocument = gql` query ListSSOIdentityProvidersByWorkspaceId { listSSOIdentityProvidersByWorkspaceId { diff --git a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainValidationEffect.tsx b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainValidationEffect.tsx new file mode 100644 index 000000000000..1c440a7a8b1b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainValidationEffect.tsx @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; +import { isDefined } from 'twenty-shared'; +import { useValidateWorkspaceTrustedDomainMutation } 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 SettingsSecurityTrustedDomainValidationEffect = () => { + const [validateWorkspaceTrustedDomainMutation] = + useValidateWorkspaceTrustedDomainMutation(); + const { enqueueSnackBar } = useSnackBar(); + const [searchParams] = useSearchParams(); + const workspaceTrustedDomainId = searchParams.get('wtdId'); + const validationToken = searchParams.get('validationToken'); + + useEffect(() => { + if (isDefined(validationToken) && isDefined(workspaceTrustedDomainId)) { + validateWorkspaceTrustedDomainMutation({ + variables: { + input: { + validationToken, + workspaceTrustedDomainId, + }, + }, + onCompleted: () => { + enqueueSnackBar('Trusted domain validated', { + variant: SnackBarVariant.Success, + }); + }, + onError: () => { + enqueueSnackBar('Error validating trusted domain', { + variant: SnackBarVariant.Error, + }); + }, + }); + } + // Validate trusted domain only needs to run once at mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsTrustedDomainsListCard.tsx b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsTrustedDomainsListCard.tsx index 018eba9ea2b0..f7888dfb05b0 100644 --- a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsTrustedDomainsListCard.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsTrustedDomainsListCard.tsx @@ -14,6 +14,7 @@ import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; import { SettingsListCard } from '@/settings/components/SettingsListCard'; import { workspaceTrustedDomainsState } from '@/settings/security/states/WorkspaceTrustedDomainsState'; import { SettingsSecurityTrustedDomainRowDropdownMenu } from '@/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainRowDropdownMenu'; +import { SettingsSecurityTrustedDomainValidationEffect } from '@/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainValidationEffect'; const StyledLink = styled(Link)` text-decoration: none; @@ -22,13 +23,12 @@ const StyledLink = styled(Link)` export const SettingsTrustedDomainsListCard = () => { const { enqueueSnackBar } = useSnackBar(); const navigate = useNavigate(); + const { t } = useLingui(); const [workspaceTrustedDomains, setWorkspaceTrustedDomains] = useRecoilState( workspaceTrustedDomainsState, ); - const { t } = useLingui(); - const { loading } = useGetAllWorkspaceTrustedDomainsQuery({ fetchPolicy: 'network-only', onCompleted: (data) => { @@ -49,22 +49,25 @@ export const SettingsTrustedDomainsListCard = () => { /> ) : ( - - `${workspaceTrustedDomain.domain} - ${workspaceTrustedDomain.createdAt}` - } - RowIcon={IconAt} - RowRightComponent={({ item: workspaceTrustedDomain }) => ( - - )} - hasFooter - footerButtonLabel="Add Trusted Domain" - onFooterButtonClick={() => - navigate(getSettingsPath(SettingsPath.NewTrustedDomain)) - } - /> + <> + + + `${workspaceTrustedDomain.domain} - ${workspaceTrustedDomain.createdAt}` + } + RowIcon={IconAt} + RowRightComponent={({ item: workspaceTrustedDomain }) => ( + + )} + hasFooter + footerButtonLabel="Add Trusted Domain" + onFooterButtonClick={() => + navigate(getSettingsPath(SettingsPath.NewTrustedDomain)) + } + /> + ); }; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/validateWorkspaceTrustedDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/validateWorkspaceTrustedDomain.ts new file mode 100644 index 000000000000..01f988769198 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/validateWorkspaceTrustedDomain.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const VALIDATE_WORKSPACE_TRUSTED_DOMAIN = gql` + mutation ValidateWorkspaceTrustedDomain($input: ValidateTrustedDomainInput!) { + validateWorkspaceTrustedDomain(input: $input) + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/hooks/useValidateTrustedDomain.ts b/packages/twenty-front/src/modules/settings/security/hooks/useValidateTrustedDomain.ts deleted file mode 100644 index a95ebfa6594a..000000000000 --- a/packages/twenty-front/src/modules/settings/security/hooks/useValidateTrustedDomain.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const useValidateTrustedDomain = () => { - const validateTrustedDomain = (validationToken: string) => {}; - - return { - validateTrustedDomain, - }; -}; From 6c35bdb5612c9c1757bf1577a3cfd6fd6a3fd1b9 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Wed, 19 Feb 2025 17:49:28 +0100 Subject: [PATCH 07/22] feat(graphql): add support for Workspace Trusted Domain management Introduce mutations and queries to create, delete, and validate Workspace Trusted Domains. This includes input types, output types, and Apollo hooks for managing trusted domains in the workspace context. --- .../src/generated-metadata/graphql.ts | 41 +++++ .../twenty-front/src/generated/graphql.tsx | 161 ++++++++++++++++++ 2 files changed, 202 insertions(+) diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 59805ee23cd2..d91a7c4d9a6c 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -394,6 +394,11 @@ export type CreateServerlessFunctionInput = { timeoutSeconds?: InputMaybe; }; +export type CreateTrustedDomainInput = { + domain: Scalars['String']['input']; + email: Scalars['String']['input']; +}; + export type CreateWorkflowVersionStepInput = { /** New step type */ stepType: Scalars['String']['input']; @@ -452,6 +457,10 @@ export type DeleteSsoOutput = { identityProviderId: Scalars['String']['output']; }; +export type DeleteTrustedDomainInput = { + id: Scalars['String']['input']; +}; + export type DeleteWorkflowVersionStepInput = { /** Step to delete ID */ stepId: Scalars['String']['input']; @@ -849,6 +858,7 @@ export type Mutation = { createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; createWorkflowVersionStep: WorkflowAction; + createWorkspaceTrustedDomain: WorkspaceTrustedDomain; deactivateWorkflowVersion: Scalars['Boolean']['output']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; @@ -860,6 +870,7 @@ export type Mutation = { deleteUser: User; deleteWorkflowVersionStep: WorkflowAction; deleteWorkspaceInvitation: Scalars['String']['output']; + deleteWorkspaceTrustedDomain: Scalars['Boolean']['output']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; @@ -900,6 +911,7 @@ export type Mutation = { uploadProfilePicture: Scalars['String']['output']; uploadWorkspaceLogo: Scalars['String']['output']; userLookupAdminPanel: UserLookup; + validateWorkspaceTrustedDomain: Scalars['Boolean']['output']; }; @@ -988,6 +1000,11 @@ export type MutationCreateWorkflowVersionStepArgs = { }; +export type MutationCreateWorkspaceTrustedDomainArgs = { + input: CreateTrustedDomainInput; +}; + + export type MutationDeactivateWorkflowVersionArgs = { workflowVersionId: Scalars['String']['input']; }; @@ -1033,6 +1050,11 @@ export type MutationDeleteWorkspaceInvitationArgs = { }; +export type MutationDeleteWorkspaceTrustedDomainArgs = { + input: DeleteTrustedDomainInput; +}; + + export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; @@ -1225,6 +1247,11 @@ export type MutationUserLookupAdminPanelArgs = { userIdentifier: Scalars['String']['input']; }; + +export type MutationValidateWorkspaceTrustedDomainArgs = { + input: ValidateTrustedDomainInput; +}; + export type Object = { __typename?: 'Object'; createdAt: Scalars['DateTime']['output']; @@ -1393,6 +1420,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; + getAllWorkspaceTrustedDomains: Array; getAvailablePackages: Scalars['JSON']['output']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getIndicatorHealthStatus: AdminPanelHealthServiceData; @@ -2118,6 +2146,11 @@ export type ValidatePasswordResetToken = { id: Scalars['String']['output']; }; +export type ValidateTrustedDomainInput = { + validationToken: Scalars['String']['input']; + workspaceTrustedDomainId: Scalars['String']['input']; +}; + export type WorkerQueueMetrics = { __typename?: 'WorkerQueueMetrics'; active: Scalars['Float']['output']; @@ -2251,6 +2284,14 @@ export type WorkspaceNameAndId = { id: Scalars['String']['output']; }; +export type WorkspaceTrustedDomain = { + __typename?: 'WorkspaceTrustedDomain'; + createdAt: Scalars['DateTime']['output']; + domain: Scalars['String']['output']; + id: Scalars['UUID']['output']; + isValidated: Scalars['Boolean']['output']; +}; + export type WorkspaceUrlsAndId = { __typename?: 'WorkspaceUrlsAndId'; id: Scalars['String']['output']; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 4e0d2403b6b5..7540d13915b7 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -2391,6 +2391,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 CreateWorkspaceTrustDomainMutationVariables = Exact<{ + input: CreateTrustedDomainInput; +}>; + + +export type CreateWorkspaceTrustDomainMutation = { __typename?: 'Mutation', createWorkspaceTrustedDomain: { __typename?: 'WorkspaceTrustedDomain', id: any, domain: string, isValidated: boolean, createdAt: string } }; + export type DeleteSsoIdentityProviderMutationVariables = Exact<{ input: DeleteSsoInput; }>; @@ -2398,6 +2405,13 @@ export type DeleteSsoIdentityProviderMutationVariables = Exact<{ export type DeleteSsoIdentityProviderMutation = { __typename?: 'Mutation', deleteSSOIdentityProvider: { __typename?: 'DeleteSsoOutput', identityProviderId: string } }; +export type DeleteWorkspaceTrustDomainMutationVariables = Exact<{ + input: DeleteTrustedDomainInput; +}>; + + +export type DeleteWorkspaceTrustDomainMutation = { __typename?: 'Mutation', deleteWorkspaceTrustedDomain: boolean }; + export type EditSsoIdentityProviderMutationVariables = Exact<{ input: EditSsoInput; }>; @@ -2405,11 +2419,23 @@ export type EditSsoIdentityProviderMutationVariables = Exact<{ export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; +export type ValidateWorkspaceTrustedDomainMutationVariables = Exact<{ + input: ValidateTrustedDomainInput; +}>; + + +export type ValidateWorkspaceTrustedDomainMutation = { __typename?: 'Mutation', validateWorkspaceTrustedDomain: boolean }; + export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never; }>; export type GetSsoIdentityProvidersQuery = { __typename?: 'Query', getSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> }; +export type GetAllWorkspaceTrustedDomainsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetAllWorkspaceTrustedDomainsQuery = { __typename?: 'Query', getAllWorkspaceTrustedDomains: Array<{ __typename?: 'WorkspaceTrustedDomain', id: any, createdAt: string, domain: string, isValidated: boolean }> }; + export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __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 } } | null, 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 } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> }; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; @@ -4363,6 +4389,42 @@ export function useCreateSamlIdentityProviderMutation(baseOptions?: Apollo.Mutat export type CreateSamlIdentityProviderMutationHookResult = ReturnType; export type CreateSamlIdentityProviderMutationResult = Apollo.MutationResult; export type CreateSamlIdentityProviderMutationOptions = Apollo.BaseMutationOptions; +export const CreateWorkspaceTrustDomainDocument = gql` + mutation CreateWorkspaceTrustDomain($input: CreateTrustedDomainInput!) { + createWorkspaceTrustedDomain(input: $input) { + id + domain + isValidated + createdAt + } +} + `; +export type CreateWorkspaceTrustDomainMutationFn = Apollo.MutationFunction; + +/** + * __useCreateWorkspaceTrustDomainMutation__ + * + * To run a mutation, you first call `useCreateWorkspaceTrustDomainMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateWorkspaceTrustDomainMutation` 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 [createWorkspaceTrustDomainMutation, { data, loading, error }] = useCreateWorkspaceTrustDomainMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateWorkspaceTrustDomainMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateWorkspaceTrustDomainDocument, options); + } +export type CreateWorkspaceTrustDomainMutationHookResult = ReturnType; +export type CreateWorkspaceTrustDomainMutationResult = Apollo.MutationResult; +export type CreateWorkspaceTrustDomainMutationOptions = Apollo.BaseMutationOptions; export const DeleteSsoIdentityProviderDocument = gql` mutation DeleteSSOIdentityProvider($input: DeleteSsoInput!) { deleteSSOIdentityProvider(input: $input) { @@ -4396,6 +4458,37 @@ export function useDeleteSsoIdentityProviderMutation(baseOptions?: Apollo.Mutati export type DeleteSsoIdentityProviderMutationHookResult = ReturnType; export type DeleteSsoIdentityProviderMutationResult = Apollo.MutationResult; export type DeleteSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions; +export const DeleteWorkspaceTrustDomainDocument = gql` + mutation DeleteWorkspaceTrustDomain($input: DeleteTrustedDomainInput!) { + deleteWorkspaceTrustedDomain(input: $input) +} + `; +export type DeleteWorkspaceTrustDomainMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteWorkspaceTrustDomainMutation__ + * + * To run a mutation, you first call `useDeleteWorkspaceTrustDomainMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteWorkspaceTrustDomainMutation` 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 [deleteWorkspaceTrustDomainMutation, { data, loading, error }] = useDeleteWorkspaceTrustDomainMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useDeleteWorkspaceTrustDomainMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteWorkspaceTrustDomainDocument, options); + } +export type DeleteWorkspaceTrustDomainMutationHookResult = ReturnType; +export type DeleteWorkspaceTrustDomainMutationResult = Apollo.MutationResult; +export type DeleteWorkspaceTrustDomainMutationOptions = Apollo.BaseMutationOptions; export const EditSsoIdentityProviderDocument = gql` mutation EditSSOIdentityProvider($input: EditSsoInput!) { editSSOIdentityProvider(input: $input) { @@ -4433,6 +4526,37 @@ export function useEditSsoIdentityProviderMutation(baseOptions?: Apollo.Mutation export type EditSsoIdentityProviderMutationHookResult = ReturnType; export type EditSsoIdentityProviderMutationResult = Apollo.MutationResult; export type EditSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions; +export const ValidateWorkspaceTrustedDomainDocument = gql` + mutation ValidateWorkspaceTrustedDomain($input: ValidateTrustedDomainInput!) { + validateWorkspaceTrustedDomain(input: $input) +} + `; +export type ValidateWorkspaceTrustedDomainMutationFn = Apollo.MutationFunction; + +/** + * __useValidateWorkspaceTrustedDomainMutation__ + * + * To run a mutation, you first call `useValidateWorkspaceTrustedDomainMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useValidateWorkspaceTrustedDomainMutation` 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 [validateWorkspaceTrustedDomainMutation, { data, loading, error }] = useValidateWorkspaceTrustedDomainMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useValidateWorkspaceTrustedDomainMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ValidateWorkspaceTrustedDomainDocument, options); + } +export type ValidateWorkspaceTrustedDomainMutationHookResult = ReturnType; +export type ValidateWorkspaceTrustedDomainMutationResult = Apollo.MutationResult; +export type ValidateWorkspaceTrustedDomainMutationOptions = Apollo.BaseMutationOptions; export const GetSsoIdentityProvidersDocument = gql` query GetSSOIdentityProviders { getSSOIdentityProviders { @@ -4471,6 +4595,43 @@ export function useGetSsoIdentityProvidersLazyQuery(baseOptions?: Apollo.LazyQue export type GetSsoIdentityProvidersQueryHookResult = ReturnType; export type GetSsoIdentityProvidersLazyQueryHookResult = ReturnType; export type GetSsoIdentityProvidersQueryResult = Apollo.QueryResult; +export const GetAllWorkspaceTrustedDomainsDocument = gql` + query GetAllWorkspaceTrustedDomains { + getAllWorkspaceTrustedDomains { + id + createdAt + domain + isValidated + } +} + `; + +/** + * __useGetAllWorkspaceTrustedDomainsQuery__ + * + * To run a query within a React component, call `useGetAllWorkspaceTrustedDomainsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetAllWorkspaceTrustedDomainsQuery` 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 } = useGetAllWorkspaceTrustedDomainsQuery({ + * variables: { + * }, + * }); + */ +export function useGetAllWorkspaceTrustedDomainsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetAllWorkspaceTrustedDomainsDocument, options); + } +export function useGetAllWorkspaceTrustedDomainsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetAllWorkspaceTrustedDomainsDocument, options); + } +export type GetAllWorkspaceTrustedDomainsQueryHookResult = ReturnType; +export type GetAllWorkspaceTrustedDomainsLazyQueryHookResult = ReturnType; +export type GetAllWorkspaceTrustedDomainsQueryResult = Apollo.QueryResult; export const DeleteUserAccountDocument = gql` mutation DeleteUserAccount { deleteUser { From 3b320bf57f734cad6559f84566879bea6dd02141 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Thu, 20 Feb 2025 09:17:52 +0100 Subject: [PATCH 08/22] add not found redirection logic if object in url param not exists (#10339) closes #10150 --- ...sePageChangeEffectNavigateLocation.test.ts | 35 +++++++++++++++++-- .../usePageChangeEffectNavigateLocation.ts | 17 +++++++++ .../app/components/AppRouterProviders.tsx | 2 +- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts index 89fa8ac42a1b..b76e2d51ab47 100644 --- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts +++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts @@ -3,6 +3,8 @@ import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePat import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus'; import { AppPath } from '@/types/AppPath'; import { useIsWorkspaceActivationStatusSuspended } from '@/workspace/hooks/useIsWorkspaceActivationStatusSuspended'; +import { useParams } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; import { OnboardingStatus } from '~/generated/graphql'; @@ -47,8 +49,30 @@ jest.mocked(useDefaultHomePagePath).mockReturnValue({ defaultHomePagePath, }); +jest.mock('react-router-dom'); +const setupMockUseParams = (objectNamePlural?: string) => { + jest + .mocked(useParams) + .mockReturnValueOnce({ objectNamePlural: objectNamePlural ?? '' }); +}; + +jest.mock('recoil'); +const setupMockRecoil = (objectNamePlural?: string) => { + jest + .mocked(useRecoilValue) + .mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }]); +}; + // prettier-ignore -const testCases = [ +const testCases: { + loc: AppPath; + isLoggedIn: boolean; + isWorkspaceSuspended: boolean; + onboardingStatus: OnboardingStatus | undefined; + res: string | undefined; + objectNamePluralFromParams?: string; + objectNamePluralFromMetadata?: string; +}[] = [ { loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, { loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: '/settings/billing' }, { loc: AppPath.Verify, isLoggedIn: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined }, @@ -183,6 +207,8 @@ const testCases = [ { loc: AppPath.RecordIndexPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, { loc: AppPath.RecordIndexPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, { loc: AppPath.RecordIndexPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.RecordIndexPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, objectNamePluralFromParams: 'existing-object', objectNamePluralFromMetadata: 'existing-object' }, + { loc: AppPath.RecordIndexPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.NotFound, objectNamePluralFromParams: 'non-existing-object', objectNamePluralFromMetadata: 'existing-object' }, { loc: AppPath.RecordShowPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, { loc: AppPath.RecordShowPage, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: '/settings/billing' }, @@ -248,6 +274,9 @@ describe('usePageChangeEffectNavigateLocation', () => { testCase.isWorkspaceSuspended, ); setupMockIsLogged(testCase.isLoggedIn); + setupMockUseParams(testCase.objectNamePluralFromParams); + setupMockRecoil(testCase.objectNamePluralFromMetadata); + expect(usePageChangeEffectNavigateLocation()).toEqual(testCase.res); }); }); @@ -257,7 +286,9 @@ describe('usePageChangeEffectNavigateLocation', () => { expect(testCases.length).toEqual( (Object.keys(AppPath).length - UNTESTED_APP_PATHS.length) * (Object.keys(OnboardingStatus).length + - ['isWorkspaceSuspended:true', 'isWorkspaceSuspended:false'].length), + ['isWorkspaceSuspended:true', 'isWorkspaceSuspended:false'] + .length) + + ['nonExistingObjectInParam', 'existingObjectInParam:false'].length, ); }); }); diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts index 76858a93e504..0ebfd2f2c621 100644 --- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts +++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts @@ -1,9 +1,13 @@ import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus'; import { AppPath } from '@/types/AppPath'; import { SettingsPath } from '@/types/SettingsPath'; import { useIsWorkspaceActivationStatusSuspended } from '@/workspace/hooks/useIsWorkspaceActivationStatusSuspended'; +import { useParams } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared'; import { OnboardingStatus } from '~/generated/graphql'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; @@ -33,6 +37,12 @@ export const usePageChangeEffectNavigateLocation = () => { isMatchingLocation(AppPath.PlanRequired) || isMatchingLocation(AppPath.PlanRequiredSuccess); + const objectNamePlural = useParams().objectNamePlural ?? ''; + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + const objectMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => objectMetadataItem.namePlural === objectNamePlural, + ); + if (isMatchingOpenRoute) { return; } @@ -96,5 +106,12 @@ export const usePageChangeEffectNavigateLocation = () => { return defaultHomePagePath; } + if ( + isMatchingLocation(AppPath.RecordIndexPage) && + !isDefined(objectMetadataItem) + ) { + return AppPath.NotFound; + } + return; }; diff --git a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx index f5c45a946c6e..39c0deab265a 100644 --- a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx +++ b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx @@ -61,8 +61,8 @@ export const AppRouterProviders = () => { + - From 99aff3045eb068a3fda33180d63f9e05e76fb63e Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Thu, 20 Feb 2025 10:16:58 +0100 Subject: [PATCH 09/22] [BUGFIX][PROD] RICH_TEXT_V2 command handle `{}` body col value (#10324) # Introduction Encountered in issue in production where we have a lot of records that has RICH_TEXT_FIELD set to `{}` ```sh [Nest] 20106 - 02/19/2025, 12:43:08 PM LOG [MigrateRichTextFieldCommand] Generating markdown for 1 records [Nest] 20106 - 02/19/2025, 12:43:09 PM LOG [MigrateRichTextFieldCommand] Error in workspace 3b8e6458-5fc1-4e63-8563-008ccddaa6db: TypeError: o is not iterable ``` ## Fix While reading `fieldValue` definition also strictly check if it's `{}` + checking after JSON parse if it's an iterable to pass to the `serverBlockNoteEditor` in order to be 100 bullet proof for prod migration command ## Refactor Dry run Implemented dry run ## Refactor to Idempotency Made the script idempotent in order to avoid issues with re-running commands ## Error repro - In local checkout on v0.41.5 run `yarn && npx nx reset && npx nx start` - Create record manually in db that has a RICH_TEXT body to `{}` - Checkout to main, `yarn && npx nx reset && npx nx build twenty-server && yarn command:prod upgrade-0.42:migrate-rich-text-field -d` --- .../0-42-migrate-rich-text-field.command.ts | 401 +++++++++++++----- .../0-42/0-42-upgrade-version.command.ts | 19 +- 2 files changed, 312 insertions(+), 108 deletions(-) diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-42/0-42-migrate-rich-text-field.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-42/0-42-migrate-rich-text-field.command.ts index a7c3b803ca50..e9b42fd43577 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version/0-42/0-42-migrate-rich-text-field.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-42/0-42-migrate-rich-text-field.command.ts @@ -2,15 +2,13 @@ import { InjectRepository } from '@nestjs/typeorm'; import { ServerBlockNoteEditor } from '@blocknote/server-util'; import chalk from 'chalk'; -import { Command } from 'nest-commander'; -import { FieldMetadataType } from 'twenty-shared'; +import { Command, Option } from 'nest-commander'; +import { FieldMetadataType, isDefined } from 'twenty-shared'; import { Repository } from 'typeorm'; -import { - ActiveWorkspacesCommandOptions, - ActiveWorkspacesCommandRunner, -} from 'src/database/commands/active-workspaces.command'; +import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command'; import { isCommandLogger } from 'src/database/commands/logger'; +import { Upgrade042CommandOptions } from 'src/database/commands/upgrade-version/0-42/0-42-upgrade-version.command'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -35,11 +33,33 @@ import { TASK_STANDARD_FIELD_IDS, } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +type MigrateRichTextContentArgs = { + richTextFieldsWithHasCreatedColumns: RichTextFieldWithHasCreatedColumnsAndObjectMetadata[]; + workspaceId: string; +}; + +type RichTextFieldWithHasCreatedColumnsAndObjectMetadata = { + richTextField: FieldMetadataEntity; + hasCreatedColumns: boolean; + objectMetadata: ObjectMetadataEntity | null; +}; + +type ProcessWorkspaceArgs = { + workspaceId: string; + index: number; + total: number; +}; + +type ProcessRichTextFieldsArgs = { + richTextFields: FieldMetadataEntity[]; + workspaceId: string; +}; @Command({ name: 'upgrade-0.42:migrate-rich-text-field', description: 'Migrate RICH_TEXT fields to new composite structure', }) export class MigrateRichTextFieldCommand extends ActiveWorkspacesCommandRunner { + private options: Upgrade042CommandOptions; constructor( @InjectRepository(Workspace, 'core') protected readonly workspaceRepository: Repository, @@ -58,22 +78,39 @@ export class MigrateRichTextFieldCommand extends ActiveWorkspacesCommandRunner { super(workspaceRepository); } + @Option({ + flags: '-f, --force [boolean]', + description: + 'Force RICH_TEXT_FIELD value update even if column migration has already be run', + required: false, + }) + parseForceValue(val?: boolean): boolean { + return val ?? false; + } + async executeActiveWorkspacesCommand( _passedParam: string[], - options: ActiveWorkspacesCommandOptions, + options: Upgrade042CommandOptions, workspaceIds: string[], ): Promise { this.logger.log( 'Running command to migrate RICH_TEXT fields to new composite structure', ); - + if (options.force) { + this.logger.warn('Running in force mode'); + } + this.options = options; if (isCommandLogger(this.logger)) { this.logger.setVerbose(options.verbose ?? false); } try { for (const [index, workspaceId] of workspaceIds.entries()) { - await this.processWorkspace(workspaceId, index, workspaceIds.length); + await this.processWorkspace({ + workspaceId, + index, + total: workspaceIds.length, + }); } this.logger.log(chalk.green('Command completed!')); @@ -82,11 +119,11 @@ export class MigrateRichTextFieldCommand extends ActiveWorkspacesCommandRunner { } } - private async processWorkspace( - workspaceId: string, - index: number, - total: number, - ): Promise { + private async processWorkspace({ + index, + total, + workspaceId, + }: ProcessWorkspaceArgs): Promise { try { this.logger.log( `Running command for workspace ${workspaceId} ${index + 1}/${total}`, @@ -109,22 +146,28 @@ export class MigrateRichTextFieldCommand extends ActiveWorkspacesCommandRunner { this.logger.log(`Found ${richTextFields.length} RICH_TEXT fields`); - for (const richTextField of richTextFields) { - await this.processRichTextField(richTextField, workspaceId); - } - - await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations( - workspaceId, - ); + const richTextFieldsWithHasCreatedColumns = + await this.createIfMissingNewRichTextFieldsColumn({ + richTextFields, + workspaceId, + }); - await this.workspaceMetadataVersionService.incrementMetadataVersion( + await this.migrateToNewRichTextFieldsColumn({ + richTextFieldsWithHasCreatedColumns, workspaceId, - ); - - await this.migrateRichTextContent(richTextFields, workspaceId); + }); await this.enableRichTextV2FeatureFlag(workspaceId); + if (!this.options.dryRun) { + await this.workspaceMetadataVersionService.incrementMetadataVersion( + workspaceId, + ); + } + + await this.twentyORMGlobalManager.destroyDataSourceForWorkspace( + workspaceId, + ); this.logger.log( chalk.green(`Command completed for workspace ${workspaceId}`), ); @@ -136,101 +179,241 @@ export class MigrateRichTextFieldCommand extends ActiveWorkspacesCommandRunner { private async enableRichTextV2FeatureFlag( workspaceId: string, ): Promise { - await this.featureFlagRepository.upsert( + if (!this.options.dryRun) { + await this.featureFlagRepository.upsert( + { + workspaceId, + key: FeatureFlagKey.IsRichTextV2Enabled, + value: true, + }, + { + conflictPaths: ['workspaceId', 'key'], + skipUpdateIfNoValuesChanged: true, + }, + ); + } + } + + private buildRichTextFieldStandardId(richTextField: FieldMetadataEntity) { + switch (true) { + case richTextField.standardId === TASK_STANDARD_FIELD_IDS.body: { + return TASK_STANDARD_FIELD_IDS.bodyV2; + } + case richTextField.standardId === NOTE_STANDARD_FIELD_IDS.body: { + return NOTE_STANDARD_FIELD_IDS.bodyV2; + } + case richTextField.isCustom: { + return null; + } + default: { + throw new Error( + `RICH_TEXT does not belong to a Task or a Note standard objects: ${richTextField.id}`, + ); + } + } + } + + private async createMarkdownBlockNoteV2Columns({ + richTextField, + workspaceId, + objectMetadata, + fieldMetadataAlreadyExisting, + }: { + objectMetadata: ObjectMetadataEntity; + richTextField: FieldMetadataEntity; + workspaceId: string; + fieldMetadataAlreadyExisting: boolean; + }) { + const columnsToCreate: WorkspaceMigrationColumnCreate[] = [ { - workspaceId, - key: FeatureFlagKey.IsRichTextV2Enabled, - value: true, + action: WorkspaceMigrationColumnActionType.CREATE, + columnName: `${richTextField.name}V2Blocknote`, + columnType: 'text', + isNullable: true, + defaultValue: null, }, { - conflictPaths: ['workspaceId', 'key'], - skipUpdateIfNoValuesChanged: true, + action: WorkspaceMigrationColumnActionType.CREATE, + columnName: `${richTextField.name}V2Markdown`, + columnType: 'text', + isNullable: true, + defaultValue: null, }, - ); + ] as const; + + const shouldForceCreateColumns = + this.options.force && fieldMetadataAlreadyExisting; + + if (shouldForceCreateColumns) { + this.logger.warn( + `Force creating V2 columns for workspaceId: ${workspaceId} objectMetadaId: ${objectMetadata.id}`, + ); + } + const shouldCreateColumns = + !fieldMetadataAlreadyExisting || shouldForceCreateColumns; + + if (!this.options.dryRun && shouldCreateColumns) { + await this.workspaceMigrationService.createCustomMigration( + generateMigrationName( + `migrate-rich-text-field-${objectMetadata.nameSingular}-${richTextField.name}`, + ), + workspaceId, + [ + { + name: computeObjectTargetTable(objectMetadata), + action: WorkspaceMigrationTableActionType.ALTER, + columns: columnsToCreate, + } satisfies WorkspaceMigrationTableAction, + ], + ); + } + + return shouldCreateColumns; } - private async processRichTextField( - richTextField: FieldMetadataEntity, - workspaceId: string, - ) { - let standardId: string | null = null; + private async createIfMissingNewRichTextFieldsColumn({ + richTextFields, + workspaceId, + }: ProcessRichTextFieldsArgs): Promise< + RichTextFieldWithHasCreatedColumnsAndObjectMetadata[] + > { + const richTextFieldsWithHasCreatedColumns: RichTextFieldWithHasCreatedColumnsAndObjectMetadata[] = + []; + + for (const richTextField of richTextFields) { + const standardId = this.buildRichTextFieldStandardId(richTextField); + + const newRichTextField: Partial = { + ...richTextField, + name: `${richTextField.name}V2`, + id: undefined, + type: FieldMetadataType.RICH_TEXT_V2, + defaultValue: null, + standardId, + workspaceId, + }; + + const existingFieldMetadata = + await this.fieldMetadataRepository.findOneBy({ + name: newRichTextField.name, + type: newRichTextField.type, + standardId: newRichTextField.standardId ?? undefined, + workspaceId, + }); + const fieldMetadataAlreadyExisting = isDefined(existingFieldMetadata); - if (richTextField.standardId === TASK_STANDARD_FIELD_IDS.body) { - standardId = TASK_STANDARD_FIELD_IDS.bodyV2; - } else if (richTextField.standardId === NOTE_STANDARD_FIELD_IDS.body) { - standardId = NOTE_STANDARD_FIELD_IDS.bodyV2; + if (fieldMetadataAlreadyExisting) { + this.logger.warn( + `FieldMetadata already exists in fieldMetadataRepository name: ${newRichTextField.name} standardId: ${newRichTextField.standardId} type: ${newRichTextField.type} workspaceId: ${workspaceId}`, + ); + } + + if (!this.options.dryRun && !fieldMetadataAlreadyExisting) { + await this.fieldMetadataRepository.insert(newRichTextField); + } + + const objectMetadata = await this.objectMetadataRepository.findOne({ + where: { id: richTextField.objectMetadataId }, + relations: { + fields: true, + }, + }); + + if (objectMetadata === null) { + this.logger.warn( + `Object metadata not found for rich text field ${richTextField.name} in workspace ${workspaceId}`, + ); + richTextFieldsWithHasCreatedColumns.push({ + hasCreatedColumns: false, + richTextField, + objectMetadata, + }); + continue; + } + + const hasCreatedColumns = await this.createMarkdownBlockNoteV2Columns({ + objectMetadata, + richTextField, + workspaceId, + fieldMetadataAlreadyExisting, + }); + + richTextFieldsWithHasCreatedColumns.push({ + hasCreatedColumns, + richTextField, + objectMetadata, + }); } - if (standardId === null && richTextField.isCustom === false) { - throw new Error( - `RICH_TEXT does not belong to a Task or a Note standard objects: ${richTextField.id}`, + const hasAtLeastOnePendingMigration = + richTextFieldsWithHasCreatedColumns.some( + ({ hasCreatedColumns }) => hasCreatedColumns, + ); + + if (!this.options.dryRun && hasAtLeastOnePendingMigration) { + await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations( + workspaceId, ); } - const newRichTextField: Partial = { - ...richTextField, - name: `${richTextField.name}V2`, - id: undefined, - type: FieldMetadataType.RICH_TEXT_V2, - defaultValue: null, - standardId, - }; + return richTextFieldsWithHasCreatedColumns; + } - await this.fieldMetadataRepository.insert(newRichTextField); + private jsonParseOrSilentlyFail(input: string): null | unknown { + try { + return JSON.parse(input); + } catch (e) { + return null; + } + } - const objectMetadata = await this.objectMetadataRepository.findOne({ - where: { id: richTextField.objectMetadataId }, - }); + private async getMardownFieldValue({ + blocknoteFieldValue, + serverBlockNoteEditor, + }: { + blocknoteFieldValue: string | null; + serverBlockNoteEditor: ServerBlockNoteEditor; + }): Promise { + const blocknoteFieldValueIsDefined = + blocknoteFieldValue !== null && + blocknoteFieldValue !== undefined && + blocknoteFieldValue !== '{}'; + + if (!blocknoteFieldValueIsDefined) { + return null; + } - if (objectMetadata === null) { - this.logger.log( - `Object metadata not found for rich text field ${richTextField.name} in workspace ${workspaceId}`, + const jsonParsedblocknoteFieldValue = + this.jsonParseOrSilentlyFail(blocknoteFieldValue); + + if (jsonParsedblocknoteFieldValue === null) { + return null; + } + + if (!Array.isArray(jsonParsedblocknoteFieldValue)) { + this.logger.warn( + `blocknoteFieldValue is defined and is not an array got ${blocknoteFieldValue}`, ); - return; + return null; } - await this.workspaceMigrationService.createCustomMigration( - generateMigrationName( - `migrate-rich-text-field-${objectMetadata.nameSingular}-${richTextField.name}`, - ), - workspaceId, - [ - { - name: computeObjectTargetTable(objectMetadata), - action: WorkspaceMigrationTableActionType.ALTER, - columns: [ - { - action: WorkspaceMigrationColumnActionType.CREATE, - columnName: `${richTextField.name}V2Blocknote`, - columnType: 'text', - isNullable: true, - defaultValue: null, - } satisfies WorkspaceMigrationColumnCreate, - { - action: WorkspaceMigrationColumnActionType.CREATE, - columnName: `${richTextField.name}V2Markdown`, - columnType: 'text', - isNullable: true, - defaultValue: null, - } satisfies WorkspaceMigrationColumnCreate, - ], - } satisfies WorkspaceMigrationTableAction, - ], + return await serverBlockNoteEditor.blocksToMarkdownLossy( + jsonParsedblocknoteFieldValue, ); } - private async migrateRichTextContent( - richTextFields: FieldMetadataEntity[], - workspaceId: string, - ) { + private async migrateToNewRichTextFieldsColumn({ + richTextFieldsWithHasCreatedColumns, + workspaceId, + }: MigrateRichTextContentArgs) { const serverBlockNoteEditor = ServerBlockNoteEditor.create(); - for (const richTextField of richTextFields) { - const objectMetadata = await this.objectMetadataRepository.findOne({ - where: { id: richTextField.objectMetadataId }, - }); - + for (const { + richTextField, + hasCreatedColumns, + objectMetadata, + } of richTextFieldsWithHasCreatedColumns) { if (objectMetadata === null) { this.logger.log( `Object metadata not found for rich text field ${richTextField.name} in workspace ${workspaceId}`, @@ -254,16 +437,22 @@ export class MigrateRichTextFieldCommand extends ActiveWorkspacesCommandRunner { for (const row of rows) { const blocknoteFieldValue = row[richTextField.name]; - const markdownFieldValue = blocknoteFieldValue - ? await serverBlockNoteEditor.blocksToMarkdownLossy( - JSON.parse(blocknoteFieldValue), - ) - : null; - - await workspaceDataSource.query( - `UPDATE "${schemaName}"."${computeTableName(objectMetadata.nameSingular, objectMetadata.isCustom)}" SET "${richTextField.name}V2Blocknote" = $1, "${richTextField.name}V2Markdown" = $2 WHERE id = $3`, - [blocknoteFieldValue, markdownFieldValue, row.id], - ); + const markdownFieldValue = await this.getMardownFieldValue({ + blocknoteFieldValue, + serverBlockNoteEditor, + }); + + if (this.options.force) { + this.logger.warn( + `Force udpate rowId: ${row.id} RICH_TEXT_FIELD ${richTextField.id} objectMetadata ${objectMetadata.id}`, + ); + } + if (!this.options.dryRun && (hasCreatedColumns || this.options.force)) { + await workspaceDataSource.query( + `UPDATE "${schemaName}"."${computeTableName(objectMetadata.nameSingular, objectMetadata.isCustom)}" SET "${richTextField.name}V2Blocknote" = $1, "${richTextField.name}V2Markdown" = $2 WHERE id = $3`, + [blocknoteFieldValue, markdownFieldValue, row.id], + ); + } } } } diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-42/0-42-upgrade-version.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-42/0-42-upgrade-version.command.ts index ef7bb6914fba..bf151112bd68 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version/0-42/0-42-upgrade-version.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-42/0-42-upgrade-version.command.ts @@ -1,6 +1,6 @@ import { InjectRepository } from '@nestjs/typeorm'; -import { Command } from 'nest-commander'; +import { Command, Option } from 'nest-commander'; import { Repository } from 'typeorm'; import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command'; @@ -11,6 +11,11 @@ import { MigrateRichTextFieldCommand } from 'src/database/commands/upgrade-versi import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command'; +type Upgrade042CommandCustomOptions = { + force: boolean; +}; +export type Upgrade042CommandOptions = BaseCommandOptions & + Upgrade042CommandCustomOptions; @Command({ name: 'upgrade-0.42', description: 'Upgrade to 0.42', @@ -27,9 +32,19 @@ export class UpgradeTo0_42Command extends ActiveWorkspacesCommandRunner { super(workspaceRepository); } + @Option({ + flags: '-f, --force [boolean]', + description: + 'Force RICH_TEXT_FIELD value update even if column migration has already be run', + required: false, + }) + parseForceValue(val?: boolean): boolean { + return val ?? false; + } + async executeActiveWorkspacesCommand( passedParam: string[], - options: BaseCommandOptions, + options: Upgrade042CommandOptions, workspaceIds: string[], ): Promise { this.logger.log('Running command to upgrade to 0.42'); From 4904ddfd9fc8083838d3d0c9718af36009bbc03c Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 20 Feb 2025 10:48:45 +0100 Subject: [PATCH 10/22] refactor(workspace-trusted-domain): improve validation flow Changed mutation to return the validated domain instead of a boolean, streamlined domain validation logic, and clarified error messages. Removed redundant utility function and adjusted email template for better user guidance. These changes enhance clarity, reduce complexity, and improve user feedback when validating trusted domains. --- .../emails/validate-trust-domain.email.tsx | 7 ++-- .../workspace-trusted-domain.service.ts | 32 ++++--------------- .../workspace-trusted-domain.resolver.ts | 8 ++--- 3 files changed, 15 insertions(+), 32 deletions(-) diff --git a/packages/twenty-emails/src/emails/validate-trust-domain.email.tsx b/packages/twenty-emails/src/emails/validate-trust-domain.email.tsx index e791edd9bff5..77d9c9664e85 100644 --- a/packages/twenty-emails/src/emails/validate-trust-domain.email.tsx +++ b/packages/twenty-emails/src/emails/validate-trust-domain.email.tsx @@ -49,8 +49,11 @@ export const SendTrustDomainValidation = ({ value={sender.email} color={emailTheme.font.colors.blue} /> - )has added a trust domain: - {domain} + ) + + Please validate this domain to allow users with @{domain} email + addresses to join your workspace without requiring an invitation. +
diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts index 0e7317099c24..ffb3ce09cc00 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts @@ -12,7 +12,6 @@ import { WorkspaceTrustedDomain as WorkspaceTrustedDomainEntity } from 'src/engi import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { isWorkDomain } from 'src/utils/is-work-email'; 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'; @@ -33,25 +32,6 @@ export class WorkspaceTrustedDomainService { private readonly domainManagerService: DomainManagerService, ) {} - private checkIsVerified( - domain: string, - inWorkspace: Workspace, - fromUser: User, - ) { - if (!isWorkDomain(domain)) return false; - - if ( - domain === inWorkspace.customDomain && - inWorkspace.isCustomDomainEnabled - ) - return true; - - if (fromUser.email.endsWith(domain) && fromUser.isEmailVerified) - return true; - - return false; - } - async sendTrustedDomainValidationEmail( sender: User, to: string, @@ -65,9 +45,9 @@ export class WorkspaceTrustedDomainService { ); } - if (!to.endsWith(workspaceTrustedDomain.domain)) { + if (to.split('@')[1] !== workspaceTrustedDomain.domain) { throw new WorkspaceTrustedDomainException( - 'Trusted domain does not match validator email', + 'Trusted domain does not match email domain', WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL, ); } @@ -154,8 +134,11 @@ export class WorkspaceTrustedDomainService { WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID, ); } - workspaceTrustedDomain.isValidated = true; - this.workspaceTrustedDomainRepository.save(workspaceTrustedDomain); + + return await this.workspaceTrustedDomainRepository.save({ + ...workspaceTrustedDomain, + isValidated: true, + }); } async createTrustedDomain( @@ -168,7 +151,6 @@ export class WorkspaceTrustedDomainService { await this.workspaceTrustedDomainRepository.save({ workspaceId: inWorkspace.id, domain, - isVerified: this.checkIsVerified(domain, inWorkspace, fromUser), }); await this.sendTrustedDomainValidationEmail( diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts index b44655d0d510..1056fd58a03e 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts @@ -46,17 +46,15 @@ export class WorkspaceTrustedDomainResolver { return true; } - @Mutation(() => Boolean) + @Mutation(() => WorkspaceTrustedDomain) async validateWorkspaceTrustedDomain( @Args('input') { validationToken, workspaceTrustedDomainId }: ValidateTrustedDomainInput, - ): Promise { - await this.workspaceTrustedDomainService.validateTrustedDomain({ + ): Promise { + return await this.workspaceTrustedDomainService.validateTrustedDomain({ validationToken, workspaceTrustedDomainId, }); - - return true; } @Query(() => [WorkspaceTrustedDomain]) From 22c0eb8318d97719c2e6b2373d6fc8504d90165b Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 20 Feb 2025 10:52:32 +0100 Subject: [PATCH 11/22] refactor(tests): streamline workspace trusted domain spec Removed redundant tests for checkIsVerified functionality to simplify the test suite. Updated createTrustedDomain tests to reflect changes in domain validation logic and naming conventions. This improves maintainability and aligns with updated business logic. --- .../services/workspace-trusted-domain.spec.ts | 109 +----------------- 1 file changed, 3 insertions(+), 106 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts index 8d2d615ec0f5..bbec5ebb7f88 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts @@ -69,110 +69,8 @@ describe('WorkspaceTrustedDomainService', () => { module.get(DomainManagerService); }); - describe('checkIsVerified', () => { - it('should mark the domain as verified if it is the workspace custom domain and custom domain is enabled', () => { - const domain = 'custom-domain.com'; - const inWorkspace = { - customDomain: domain, - isCustomDomainEnabled: true, - } as Workspace; - const fromUser = { - email: 'user@otherdomain.com', - isEmailVerified: true, - } as User; - - const result = (service as any).checkIsVerified( - domain, - inWorkspace, - fromUser, - ); - - expect(result).toBe(true); - }); - - it('should mark the domain as verified if the user email ends with the domain and the user email is verified', () => { - const domain = 'custom-domain.com'; - const inWorkspace = { - customDomain: null, - isCustomDomainEnabled: false, - } as Workspace; - const fromUser = { - email: 'user@custom-domain.com', - isEmailVerified: true, - } as User; - - const result = (service as any).checkIsVerified( - domain, - inWorkspace, - fromUser, - ); - - expect(result).toBe(true); - }); - - it('should not mark the domain as verified if it is not a work domain', () => { - const domain = 'gmail.com'; - const inWorkspace = { - customDomain: null, - isCustomDomainEnabled: false, - } as Workspace; - const fromUser = { - email: 'user@gmail.com', - isEmailVerified: true, - } as User; - - const result = (service as any).checkIsVerified( - domain, - inWorkspace, - fromUser, - ); - - expect(result).toBe(false); - }); - - it('should not mark the domain as verified if it is the workspace custom domain but custom domain is not enabled', () => { - const domain = 'custom-domain.com'; - const inWorkspace = { - customDomain: domain, - isCustomDomainEnabled: false, - } as Workspace; - const fromUser = { - email: 'user@otherdomain.com', - isEmailVerified: true, - } as User; - - const result = (service as any).checkIsVerified( - domain, - inWorkspace, - fromUser, - ); - - expect(result).toBe(false); - }); - - it('should not mark the domain as verified if the user email does not end with the domain or is not verified', () => { - const domain = 'example.com'; - const inWorkspace = { - customDomain: null, - isCustomDomainEnabled: false, - } as Workspace; - const fromUser = { - email: 'user@otherdomain.com', - isEmailVerified: false, - } as User; - - const result = (service as any).checkIsVerified( - domain, - inWorkspace, - fromUser, - ); - - expect(result).toBe(false); - }); - }); - describe('createTrustedDomain', () => { - it('should successfully create a trusted domain and mark it as verified based on checkIsVerified', async () => { + it('should successfully create a trusted domain', async () => { const domain = 'custom-domain.com'; const inWorkspace = { id: 'workspace-id', @@ -187,7 +85,7 @@ describe('WorkspaceTrustedDomainService', () => { const expectedTrustedDomain = { workspaceId: 'workspace-id', domain, - isVerified: true, + isValidated: true, }; jest @@ -211,7 +109,6 @@ describe('WorkspaceTrustedDomainService', () => { expect.objectContaining({ workspaceId: 'workspace-id', domain, - isVerified: true, }), ); expect(result).toEqual(expectedTrustedDomain); @@ -320,7 +217,7 @@ describe('WorkspaceTrustedDomainService', () => { ), ).rejects.toThrowError( new WorkspaceTrustedDomainException( - 'Trusted domain does not match validator email', + 'Trusted domain does not match email domain', WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL, ), ); From d5fedbb18b4d8a349d4a049c92785e88fab4bca8 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 20 Feb 2025 11:51:00 +0100 Subject: [PATCH 12/22] refactor(database): rename WorkspaceTrustedDomain to ApprovedAccessDomain Replaced all instances of WorkspaceTrustedDomain with ApprovedAccessDomain across the application. Updated related entities, migrations, services, resolvers, and schemas to reflect the new naming convention, improving clarity and alignment with domain naming standards. --- packages/twenty-front/codegen-metadata.cjs | 1 + packages/twenty-front/codegen.cjs | 1 + .../src/generated-metadata/graphql.ts | 8 +- .../twenty-front/src/generated/graphql.tsx | 74 ++-- ...9955345446-add-workspace-trusted-domain.ts | 23 -- ...740048555744-add-approved-access-domain.ts | 16 + .../src/database/typeorm/typeorm.service.ts | 4 +- .../approved-access-domain.entity.ts} | 6 +- .../approved-access-domain.exception.ts | 15 + .../approved-access-domain.module.ts | 18 + .../approved-access-domain.resolver.ts | 71 ++++ .../approved-access-domain.validate.ts | 26 ++ .../dtos/approved-access-domain.dto.ts} | 4 +- .../create-approved-access.domain.input.ts} | 2 +- .../delete-approved-access-domain.input.ts} | 2 +- .../validate-approved-access-domain.input.ts} | 4 +- .../approved-access-domain.service.ts | 184 +++++++++ .../services/approved-access-domain.spec.ts | 390 ++++++++++++++++++ .../engine/core-modules/core-engine.module.ts | 4 +- .../services/workspace-trusted-domain.spec.ts | 387 ----------------- .../workspace-trusted-domain.exception.ts | 15 - .../workspace-trusted-domain.module.ts | 18 - .../workspace-trusted-domain.validate.ts | 26 -- .../workspace/workspace.entity.ts | 8 +- 24 files changed, 780 insertions(+), 527 deletions(-) delete mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1739955345446-add-workspace-trusted-domain.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1740048555744-add-approved-access-domain.ts rename packages/twenty-server/src/engine/core-modules/{workspace-trusted-domain/workspace-trusted-domain.entity.ts => approved-access-domain/approved-access-domain.entity.ts} (84%) create mode 100644 packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.validate.ts rename packages/twenty-server/src/engine/core-modules/{workspace-trusted-domain/dtos/trusted-domain.dto.ts => approved-access-domain/dtos/approved-access-domain.dto.ts} (84%) rename packages/twenty-server/src/engine/core-modules/{workspace-trusted-domain/dtos/create-trusted-domain.input.ts => approved-access-domain/dtos/create-approved-access.domain.input.ts} (85%) rename packages/twenty-server/src/engine/core-modules/{workspace-trusted-domain/dtos/delete-trusted-domain.input.ts => approved-access-domain/dtos/delete-approved-access-domain.input.ts} (76%) rename packages/twenty-server/src/engine/core-modules/{workspace-trusted-domain/dtos/validate-trusted-domain.input.ts => approved-access-domain/dtos/validate-approved-access-domain.input.ts} (75%) create mode 100644 packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.validate.ts diff --git a/packages/twenty-front/codegen-metadata.cjs b/packages/twenty-front/codegen-metadata.cjs index 53429715ca0f..d95a37eb71b6 100644 --- a/packages/twenty-front/codegen-metadata.cjs +++ b/packages/twenty-front/codegen-metadata.cjs @@ -1,3 +1,4 @@ +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; module.exports = { schema: (process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') + diff --git a/packages/twenty-front/codegen.cjs b/packages/twenty-front/codegen.cjs index 05effffb7a22..24269a37e30f 100644 --- a/packages/twenty-front/codegen.cjs +++ b/packages/twenty-front/codegen.cjs @@ -1,3 +1,4 @@ +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; module.exports = { schema: (process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') + diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index d91a7c4d9a6c..d0904013bfd0 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -858,7 +858,7 @@ export type Mutation = { createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; createWorkflowVersionStep: WorkflowAction; - createWorkspaceTrustedDomain: WorkspaceTrustedDomain; + createWorkspaceTrustedDomain: ApprovedAccessDomain; deactivateWorkflowVersion: Scalars['Boolean']['output']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; @@ -1420,7 +1420,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; - getAllWorkspaceTrustedDomains: Array; + getAllWorkspaceTrustedDomains: Array; getAvailablePackages: Scalars['JSON']['output']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getIndicatorHealthStatus: AdminPanelHealthServiceData; @@ -2284,8 +2284,8 @@ export type WorkspaceNameAndId = { id: Scalars['String']['output']; }; -export type WorkspaceTrustedDomain = { - __typename?: 'WorkspaceTrustedDomain'; +export type ApprovedAccessDomain = { + __typename?: 'ApprovedAccessDomain'; createdAt: Scalars['DateTime']['output']; domain: Scalars['String']['output']; id: Scalars['UUID']['output']; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index b1f2549114df..c968f403acbe 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -95,6 +95,14 @@ export type AppTokenEdge = { node: AppToken; }; +export type ApprovedAccessDomain = { + __typename?: 'ApprovedAccessDomain'; + createdAt: Scalars['DateTime']; + domain: Scalars['String']; + id: Scalars['UUID']; + isValidated: Scalars['Boolean']; +}; + export type AuthProviders = { __typename?: 'AuthProviders'; google: Scalars['Boolean']; @@ -294,6 +302,11 @@ export type ComputeStepOutputSchemaInput = { step: Scalars['JSON']; }; +export type CreateApprovedAccessDomainInput = { + domain: Scalars['String']; + email: Scalars['String']; +}; + export type CreateDraftFromWorkflowVersionInput = { /** Workflow ID */ workflowId: Scalars['String']; @@ -331,11 +344,6 @@ export type CreateServerlessFunctionInput = { timeoutSeconds?: InputMaybe; }; -export type CreateTrustedDomainInput = { - domain: Scalars['String']; - email: Scalars['String']; -}; - export type CreateWorkflowVersionStepInput = { /** New step type */ stepType: Scalars['String']; @@ -370,6 +378,10 @@ export type CustomDomainValidRecords = { records: Array; }; +export type DeleteApprovedAccessDomainInput = { + id: Scalars['String']; +}; + export type DeleteOneFieldInput = { /** The id of the field to delete. */ id: Scalars['UUID']; @@ -389,10 +401,6 @@ export type DeleteSsoOutput = { identityProviderId: Scalars['String']; }; -export type DeleteTrustedDomainInput = { - id: Scalars['String']; -}; - export type DeleteWorkflowVersionStepInput = { /** Step to delete ID */ stepId: Scalars['String']; @@ -773,6 +781,7 @@ export type Mutation = { checkCustomDomainValidRecords?: Maybe; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']; + createApprovedAccessDomain: ApprovedAccessDomain; createDraftFromWorkflowVersion: WorkflowVersion; createOIDCIdentityProvider: SetupSsoOutput; createOneAppToken: AppToken; @@ -781,8 +790,8 @@ export type Mutation = { createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; createWorkflowVersionStep: WorkflowAction; - createWorkspaceTrustedDomain: WorkspaceTrustedDomain; deactivateWorkflowVersion: Scalars['Boolean']; + deleteApprovedAccessDomain: Scalars['Boolean']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; deleteOneObject: Object; @@ -791,7 +800,6 @@ export type Mutation = { deleteUser: User; deleteWorkflowVersionStep: WorkflowAction; deleteWorkspaceInvitation: Scalars['String']; - deleteWorkspaceTrustedDomain: Scalars['Boolean']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; @@ -828,7 +836,7 @@ export type Mutation = { uploadProfilePicture: Scalars['String']; uploadWorkspaceLogo: Scalars['String']; userLookupAdminPanel: UserLookup; - validateWorkspaceTrustedDomain: Scalars['Boolean']; + validateApprovedAccessDomain: ApprovedAccessDomain; }; @@ -867,6 +875,11 @@ export type MutationComputeStepOutputSchemaArgs = { }; +export type MutationCreateApprovedAccessDomainArgs = { + input: CreateApprovedAccessDomainInput; +}; + + export type MutationCreateDraftFromWorkflowVersionArgs = { input: CreateDraftFromWorkflowVersionInput; }; @@ -897,13 +910,13 @@ export type MutationCreateWorkflowVersionStepArgs = { }; -export type MutationCreateWorkspaceTrustedDomainArgs = { - input: CreateTrustedDomainInput; +export type MutationDeactivateWorkflowVersionArgs = { + workflowVersionId: Scalars['String']; }; -export type MutationDeactivateWorkflowVersionArgs = { - workflowVersionId: Scalars['String']; +export type MutationDeleteApprovedAccessDomainArgs = { + input: DeleteApprovedAccessDomainInput; }; @@ -937,11 +950,6 @@ export type MutationDeleteWorkspaceInvitationArgs = { }; -export type MutationDeleteWorkspaceTrustedDomainArgs = { - input: DeleteTrustedDomainInput; -}; - - export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; @@ -1115,8 +1123,8 @@ export type MutationUserLookupAdminPanelArgs = { }; -export type MutationValidateWorkspaceTrustedDomainArgs = { - input: ValidateTrustedDomainInput; +export type MutationValidateApprovedAccessDomainArgs = { + input: ValidateApprovedAccessDomainInput; }; export type Object = { @@ -1284,7 +1292,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; - getAllWorkspaceTrustedDomains: Array; + getAllApprovedAccessDomains: Array; getAvailablePackages: Scalars['JSON']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getIndicatorHealthStatus: AdminPanelHealthServiceData; @@ -1918,17 +1926,17 @@ export type UserWorkspace = { workspaceId: Scalars['String']; }; +export type ValidateApprovedAccessDomainInput = { + approvedAccessDomainId: Scalars['String']; + validationToken: Scalars['String']; +}; + export type ValidatePasswordResetToken = { __typename?: 'ValidatePasswordResetToken'; email: Scalars['String']; id: Scalars['String']; }; -export type ValidateTrustedDomainInput = { - validationToken: Scalars['String']; - workspaceTrustedDomainId: Scalars['String']; -}; - export type WorkerQueueMetrics = { __typename?: 'WorkerQueueMetrics'; active: Scalars['Float']; @@ -2062,14 +2070,6 @@ export type WorkspaceNameAndId = { id: Scalars['String']; }; -export type WorkspaceTrustedDomain = { - __typename?: 'WorkspaceTrustedDomain'; - createdAt: Scalars['DateTime']; - domain: Scalars['String']; - id: Scalars['UUID']; - isValidated: Scalars['Boolean']; -}; - export type WorkspaceUrlsAndId = { __typename?: 'WorkspaceUrlsAndId'; id: Scalars['String']; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1739955345446-add-workspace-trusted-domain.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1739955345446-add-workspace-trusted-domain.ts deleted file mode 100644 index 93966d5266be..000000000000 --- a/packages/twenty-server/src/database/typeorm/core/migrations/common/1739955345446-add-workspace-trusted-domain.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class AddWorkspaceTrustedDomain1739955345446 - implements MigrationInterface -{ - name = 'AddWorkspaceTrustedDomain1739955345446'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `CREATE TABLE "core"."workspaceTrustedDomain" ("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_afa04c0f75f54a5e2c570c83cd6" PRIMARY KEY ("id"))`, - ); - await queryRunner.query( - `ALTER TABLE "core"."workspaceTrustedDomain" ADD CONSTRAINT "FK_130f179c3608a3d8cde9d355d2e" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `ALTER TABLE "core"."workspaceTrustedDomain" DROP CONSTRAINT "FK_130f179c3608a3d8cde9d355d2e"`, - ); - await queryRunner.query(`DROP TABLE "core"."workspaceTrustedDomain"`); - } -} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1740048555744-add-approved-access-domain.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1740048555744-add-approved-access-domain.ts new file mode 100644 index 000000000000..84e6cc98c15d --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1740048555744-add-approved-access-domain.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddApprovedAccessDomain1740048555744 implements MigrationInterface { + name = 'AddApprovedAccessDomain1740048555744' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`ALTER TABLE "core"."approvedAccessDomain" DROP CONSTRAINT "FK_73d3e340b6ce0716a25a86361fc"`); + await queryRunner.query(`DROP TABLE "core"."approvedAccessDomain"`); + } + +} diff --git a/packages/twenty-server/src/database/typeorm/typeorm.service.ts b/packages/twenty-server/src/database/typeorm/typeorm.service.ts index 17ab3f3c7765..9c498c49712e 100644 --- a/packages/twenty-server/src/database/typeorm/typeorm.service.ts +++ b/packages/twenty-server/src/database/typeorm/typeorm.service.ts @@ -22,7 +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 { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.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; @@ -51,7 +51,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy { BillingEntitlement, PostgresCredentials, WorkspaceSSOIdentityProvider, - WorkspaceTrustedDomain, + ApprovedAccessDomain, TwoFactorMethod, ], metadataTableName: '_typeorm_generated_columns_and_materialized_views', diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.entity.ts similarity index 84% rename from packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts rename to packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.entity.ts index ec9608b416d1..bfbab3a4b672 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.entity.ts @@ -15,10 +15,10 @@ import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/i import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -@Entity({ name: 'workspaceTrustedDomain', schema: 'core' }) +@Entity({ name: 'approvedAccessDomain', schema: 'core' }) @ObjectType() @Unique('IndexOnDomainAndWorkspaceId', ['domain', 'workspaceId']) -export class WorkspaceTrustedDomain { +export class ApprovedAccessDomain { @PrimaryGeneratedColumn('uuid') id: string; @@ -37,7 +37,7 @@ export class WorkspaceTrustedDomain { @Column() workspaceId: string; - @ManyToOne(() => Workspace, (workspace) => workspace.trustDomains, { + @ManyToOne(() => Workspace, (workspace) => workspace.approvedAccessDomains, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'workspaceId' }) diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.exception.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.exception.ts new file mode 100644 index 000000000000..433407aa7ebd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.exception.ts @@ -0,0 +1,15 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class ApprovedAccessDomainException extends CustomException { + constructor(message: string, code: ApprovedAccessDomainExceptionCode) { + super(message, code); + } +} + +export enum ApprovedAccessDomainExceptionCode { + APPROVED_ACCESS_DOMAIN_NOT_FOUND = 'APPROVED_ACCESS_DOMAIN_NOT_FOUND', + APPROVED_ACCESS_DOMAIN_ALREADY_VERIFIED = 'APPROVED_ACCESS_DOMAIN_ALREADY_VERIFIED', + APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL = 'APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL', + APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID = 'APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID', + APPROVED_ACCESS_DOMAIN_ALREADY_VALIDATED = 'APPROVED_ACCESS_DOMAIN_ALREADY_VALIDATED', +} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.module.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.module.ts new file mode 100644 index 000000000000..ee7654fede3d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; + +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; + +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; +import { ApprovedAccessDomainResolver } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.resolver'; +import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; + +@Module({ + imports: [ + DomainManagerModule, + NestjsQueryTypeOrmModule.forFeature([ApprovedAccessDomain], 'core'), + ], + exports: [ApprovedAccessDomainService], + providers: [ApprovedAccessDomainService, ApprovedAccessDomainResolver], +}) +export class ApprovedAccessDomainModule {} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts new file mode 100644 index 000000000000..1c9b3a3f4c4d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts @@ -0,0 +1,71 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver, Query } from '@nestjs/graphql'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/dtos/approved-access-domain.dto'; +import { CreateApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input'; +import { DeleteApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/delete-approved-access-domain.input'; +import { ValidateApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/validate-approved-access-domain.input'; + +@UseGuards(WorkspaceAuthGuard) +@Resolver() +export class ApprovedAccessDomainResolver { + constructor( + private readonly approvedAccessDomainService: ApprovedAccessDomainService, + ) {} + + @Mutation(() => ApprovedAccessDomain) + async createApprovedAccessDomain( + @Args('input') { domain, email }: CreateApprovedAccessDomainInput, + @AuthWorkspace() currentWorkspace: Workspace, + @AuthUser() currentUser: User, + ): Promise { + return this.approvedAccessDomainService.createApprovedAccessDomain( + domain, + currentWorkspace, + currentUser, + email, + ); + } + + @Mutation(() => Boolean) + async deleteApprovedAccessDomain( + @Args('input') { id }: DeleteApprovedAccessDomainInput, + @AuthWorkspace() currentWorkspace: Workspace, + ): Promise { + await this.approvedAccessDomainService.deleteApprovedAccessDomain( + currentWorkspace, + id, + ); + + return true; + } + + @Mutation(() => ApprovedAccessDomain) + async validateApprovedAccessDomain( + @Args('input') + { + validationToken, + approvedAccessDomainId, + }: ValidateApprovedAccessDomainInput, + ): Promise { + return await this.approvedAccessDomainService.validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId, + }); + } + + @Query(() => [ApprovedAccessDomain]) + async getAllApprovedAccessDomains( + @AuthWorkspace() currentWorkspace: Workspace, + ): Promise> { + return await this.approvedAccessDomainService.getAllApprovedAccessDomains( + currentWorkspace, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.validate.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.validate.ts new file mode 100644 index 000000000000..8d038f619bae --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.validate.ts @@ -0,0 +1,26 @@ +import { isDefined } from 'twenty-shared'; + +import { CustomException } from 'src/utils/custom-exception'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; +import { + ApprovedAccessDomainException, + ApprovedAccessDomainExceptionCode, +} from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception'; + +const assertIsDefinedOrThrow = ( + approvedAccessDomain: ApprovedAccessDomain | undefined | null, + exceptionToThrow: CustomException = new ApprovedAccessDomainException( + 'Approved access domain not found', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_NOT_FOUND, + ), +): asserts approvedAccessDomain is ApprovedAccessDomain => { + if (!isDefined(approvedAccessDomain)) { + throw exceptionToThrow; + } +}; + +export const approvedAccessDomainValidator: { + assertIsDefinedOrThrow: typeof assertIsDefinedOrThrow; +} = { + assertIsDefinedOrThrow, +}; diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/approved-access-domain.dto.ts similarity index 84% rename from packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto.ts rename to packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/approved-access-domain.dto.ts index 30a0a239132f..3ac698369621 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/approved-access-domain.dto.ts @@ -4,8 +4,8 @@ import { IDField } from '@ptc-org/nestjs-query-graphql'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; -@ObjectType('WorkspaceTrustedDomain') -export class WorkspaceTrustedDomain { +@ObjectType('ApprovedAccessDomain') +export class ApprovedAccessDomain { @IDField(() => UUIDScalarType) id: string; diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input.ts similarity index 85% rename from packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts rename to packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input.ts index c292083b3afc..abd26c50bba7 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input.ts @@ -3,7 +3,7 @@ import { InputType, Field } from '@nestjs/graphql'; import { IsString, IsEmail, IsNotEmpty } from 'class-validator'; @InputType() -export class CreateTrustedDomainInput { +export class CreateApprovedAccessDomainInput { @Field(() => String) @IsString() @IsNotEmpty() diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/delete-trusted-domain.input.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/delete-approved-access-domain.input.ts similarity index 76% rename from packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/delete-trusted-domain.input.ts rename to packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/delete-approved-access-domain.input.ts index 096cbf256d10..586c3acd34a0 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/delete-trusted-domain.input.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/delete-approved-access-domain.input.ts @@ -3,7 +3,7 @@ import { Field, InputType } from '@nestjs/graphql'; import { IsString } from 'class-validator'; @InputType() -export class DeleteTrustedDomainInput { +export class DeleteApprovedAccessDomainInput { @Field() @IsString() id: string; diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/validate-trusted-domain.input.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/validate-approved-access-domain.input.ts similarity index 75% rename from packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/validate-trusted-domain.input.ts rename to packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/validate-approved-access-domain.input.ts index 7af2d422d440..0ef691b1a129 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/validate-trusted-domain.input.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/validate-approved-access-domain.input.ts @@ -3,7 +3,7 @@ import { Field, InputType } from '@nestjs/graphql'; import { IsString, IsNotEmpty } from 'class-validator'; @InputType() -export class ValidateTrustedDomainInput { +export class ValidateApprovedAccessDomainInput { @Field(() => String) @IsString() @IsNotEmpty() @@ -12,5 +12,5 @@ export class ValidateTrustedDomainInput { @Field(() => String) @IsString() @IsNotEmpty() - workspaceTrustedDomainId: string; + approvedAccessDomainId: string; } diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts new file mode 100644 index 000000000000..2c473aadf00d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts @@ -0,0 +1,184 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import crypto from 'crypto'; + +import { render } from '@react-email/render'; +import { Repository } from 'typeorm'; +import { APP_LOCALES } from 'twenty-shared'; +import { SendTrustDomainValidation } 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, + 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 = SendTrustDomainValidation({ + 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 { + 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 getAllApprovedAccessDomains(workspace: Workspace) { + return await this.approvedAccessDomainRepository.find({ + where: { + workspaceId: workspace.id, + }, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts new file mode 100644 index 000000000000..bc506c63b41c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts @@ -0,0 +1,390 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { DeleteResult, Repository } from 'typeorm'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; +import { + ApprovedAccessDomainException, + ApprovedAccessDomainExceptionCode, +} from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception'; + +import { ApprovedAccessDomainService } from './approved-access-domain.service'; + +describe('ApprovedAccessDomainService', () => { + let service: ApprovedAccessDomainService; + let approvedAccessDomainRepository: Repository; + 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, + ); + approvedAccessDomainRepository = module.get( + getRepositoryToken(ApprovedAccessDomain, 'core'), + ); + emailService = module.get(EmailService); + environmentService = module.get(EnvironmentService); + domainManagerService = + module.get(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) ', + to: email, + subject: 'Approve your access domain', + text: expect.any(String), + html: expect.any(String), + }); + }); + }); + + describe('validateApprovedAccessDomain', () => { + it('should validate the approved access domain successfully with a correct token', async () => { + const approvedAccessDomainId = 'domain-id'; + const validationToken = 'valid-token'; + const approvedAccessDomain = { + id: approvedAccessDomainId, + domain: 'example.com', + isValidated: false, + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomain); + jest + .spyOn(service as any, 'generateUniqueHash') + .mockReturnValue(validationToken); + const saveSpy = jest.spyOn(approvedAccessDomainRepository, 'save'); + + await service.validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId: approvedAccessDomainId, + }); + + expect(approvedAccessDomainRepository.findOneBy).toHaveBeenCalledWith({ + id: approvedAccessDomainId, + }); + expect(saveSpy).toHaveBeenCalledWith( + expect.objectContaining({ isValidated: true }), + ); + }); + + it('should throw an error if the approved access domain does not exist', async () => { + const approvedAccessDomainId = 'invalid-domain-id'; + const validationToken = 'valid-token'; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(null); + + await expect( + service.validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId: approvedAccessDomainId, + }), + ).rejects.toThrowError( + new ApprovedAccessDomainException( + 'Approved access domain not found', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_NOT_FOUND, + ), + ); + }); + + it('should throw an error if the validation token is invalid', async () => { + const approvedAccessDomainId = 'domain-id'; + const validationToken = 'invalid-token'; + const approvedAccessDomain = { + id: approvedAccessDomainId, + domain: 'example.com', + isValidated: false, + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomain); + jest + .spyOn(service as any, 'generateUniqueHash') + .mockReturnValue('valid-token'); + + await expect( + service.validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId: approvedAccessDomainId, + }), + ).rejects.toThrowError( + new ApprovedAccessDomainException( + 'Invalid approved access domain validation token', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID, + ), + ); + }); + + it('should throw an error if the approved access domain is already validated', async () => { + const approvedAccessDomainId = 'domain-id'; + const validationToken = 'valid-token'; + const approvedAccessDomain = { + id: approvedAccessDomainId, + domain: 'example.com', + isValidated: true, + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomain); + + await expect( + service.validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId: approvedAccessDomainId, + }), + ).rejects.toThrowError( + new ApprovedAccessDomainException( + 'Approved access domain has already been validated', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_VALIDATED, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 74c21ae191a0..6926e75a317b 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -46,7 +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 { WorkspaceTrustedDomainModule } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.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'; @@ -69,7 +69,7 @@ import { FileModule } from './file/file.module'; WorkspaceModule, WorkspaceInvitationModule, WorkspaceSSOModule, - WorkspaceTrustedDomainModule, + ApprovedAccessDomainModule, PostgresCredentialsModule, WorkflowApiModule, WorkspaceEventEmitterModule, diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts deleted file mode 100644 index bbec5ebb7f88..000000000000 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; - -import { DeleteResult, Repository } from 'typeorm'; - -import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; -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 { - WorkspaceTrustedDomainException, - WorkspaceTrustedDomainExceptionCode, -} from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception'; - -import { WorkspaceTrustedDomainService } from './workspace-trusted-domain.service'; - -describe('WorkspaceTrustedDomainService', () => { - let service: WorkspaceTrustedDomainService; - let workspaceTrustedDomainRepository: Repository; - let emailService: EmailService; - let environmentService: EnvironmentService; - let domainManagerService: DomainManagerService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - WorkspaceTrustedDomainService, - { - provide: getRepositoryToken(WorkspaceTrustedDomain, '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( - WorkspaceTrustedDomainService, - ); - workspaceTrustedDomainRepository = module.get( - getRepositoryToken(WorkspaceTrustedDomain, 'core'), - ); - emailService = module.get(EmailService); - environmentService = module.get(EnvironmentService); - domainManagerService = - module.get(DomainManagerService); - }); - - describe('createTrustedDomain', () => { - it('should successfully create a trusted 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 expectedTrustedDomain = { - workspaceId: 'workspace-id', - domain, - isValidated: true, - }; - - jest - .spyOn(workspaceTrustedDomainRepository, 'save') - .mockResolvedValue( - expectedTrustedDomain as unknown as WorkspaceTrustedDomain, - ); - - jest - .spyOn(service, 'sendTrustedDomainValidationEmail') - .mockResolvedValue(); - - const result = await service.createTrustedDomain( - domain, - inWorkspace, - fromUser, - 'validator@custom-domain.com', - ); - - expect(workspaceTrustedDomainRepository.save).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'workspace-id', - domain, - }), - ); - expect(result).toEqual(expectedTrustedDomain); - }); - }); - - describe('deleteTrustedDomain', () => { - it('should delete a trusted domain successfully', async () => { - const workspace: Workspace = { id: 'workspace-id' } as Workspace; - const trustedDomainId = 'trusted-domain-id'; - const trustedDomainEntity = { - id: trustedDomainId, - workspaceId: workspace.id, - } as WorkspaceTrustedDomain; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(trustedDomainEntity); - jest - .spyOn(workspaceTrustedDomainRepository, 'delete') - .mockResolvedValue({} as unknown as DeleteResult); - - await service.deleteTrustedDomain(workspace, trustedDomainId); - - expect(workspaceTrustedDomainRepository.findOneBy).toHaveBeenCalledWith({ - id: trustedDomainId, - workspaceId: workspace.id, - }); - expect(workspaceTrustedDomainRepository.delete).toHaveBeenCalledWith( - trustedDomainEntity, - ); - }); - - it('should throw an error if the trusted domain does not exist', async () => { - const workspace: Workspace = { id: 'workspace-id' } as Workspace; - const trustedDomainId = 'trusted-domain-id'; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(null); - - await expect( - service.deleteTrustedDomain(workspace, trustedDomainId), - ).rejects.toThrow(); - - expect(workspaceTrustedDomainRepository.findOneBy).toHaveBeenCalledWith({ - id: trustedDomainId, - workspaceId: workspace.id, - }); - expect(workspaceTrustedDomainRepository.delete).not.toHaveBeenCalled(); - }); - }); - - describe('sendTrustedDomainValidationEmail', () => { - it('should throw an exception if the trusted domain is already validated', async () => { - const trustedDomainId = 'trusted-domain-id'; - const sender = {} as User; - const workspace = {} as Workspace; - const email = 'validator@example.com'; - - const trustedDomain = { - id: trustedDomainId, - isValidated: true, - } as WorkspaceTrustedDomain; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(trustedDomain); - - await expect( - service.sendTrustedDomainValidationEmail( - sender, - email, - workspace, - trustedDomain, - ), - ).rejects.toThrowError( - new WorkspaceTrustedDomainException( - 'Trusted domain has already been validated', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED, - ), - ); - }); - - it('should throw an exception if the email does not match the trusted domain', async () => { - const trustedDomainId = 'trusted-domain-id'; - const sender = {} as User; - const workspace = {} as Workspace; - const email = 'validator@different.com'; - const trustedDomain = { - id: trustedDomainId, - isValidated: false, - domain: 'example.com', - } as WorkspaceTrustedDomain; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(trustedDomain); - - await expect( - service.sendTrustedDomainValidationEmail( - sender, - email, - workspace, - trustedDomain, - ), - ).rejects.toThrowError( - new WorkspaceTrustedDomainException( - 'Trusted domain does not match email domain', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_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 trustedDomain = { - isValidated: false, - domain: 'custom-domain.com', - } as WorkspaceTrustedDomain; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(trustedDomain); - - 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.sendTrustedDomainValidationEmail( - sender, - email, - workspace, - trustedDomain, - ); - - expect(domainManagerService.buildWorkspaceURL).toHaveBeenCalledWith({ - workspace: workspace, - pathname: 'settings/security', - searchParams: { validationToken: expect.any(String) }, - }); - - expect(emailService.send).toHaveBeenCalledWith({ - from: 'John Doe (via Twenty) ', - to: email, - subject: 'Activate Your Trusted Domain', - text: expect.any(String), - html: expect.any(String), - }); - }); - }); - - describe('validateTrustedDomain', () => { - it('should validate the trusted domain successfully with a correct token', async () => { - const trustedDomainId = 'domain-id'; - const validationToken = 'valid-token'; - const mockTrustedDomain = { - id: trustedDomainId, - domain: 'example.com', - isValidated: false, - } as WorkspaceTrustedDomain; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(mockTrustedDomain); - jest - .spyOn(service as any, 'generateUniqueHash') - .mockReturnValue(validationToken); - const saveSpy = jest.spyOn(workspaceTrustedDomainRepository, 'save'); - - await service.validateTrustedDomain({ - validationToken, - workspaceTrustedDomainId: trustedDomainId, - }); - - expect(workspaceTrustedDomainRepository.findOneBy).toHaveBeenCalledWith({ - id: trustedDomainId, - }); - expect(saveSpy).toHaveBeenCalledWith( - expect.objectContaining({ isValidated: true }), - ); - }); - - it('should throw an error if the trusted domain does not exist', async () => { - const trustedDomainId = 'invalid-domain-id'; - const validationToken = 'valid-token'; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(null); - - await expect( - service.validateTrustedDomain({ - validationToken, - workspaceTrustedDomainId: trustedDomainId, - }), - ).rejects.toThrowError( - new WorkspaceTrustedDomainException( - 'Trusted domain not found', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND, - ), - ); - }); - - it('should throw an error if the validation token is invalid', async () => { - const trustedDomainId = 'domain-id'; - const validationToken = 'invalid-token'; - const mockTrustedDomain = { - id: trustedDomainId, - domain: 'example.com', - isValidated: false, - } as WorkspaceTrustedDomain; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(mockTrustedDomain); - jest - .spyOn(service as any, 'generateUniqueHash') - .mockReturnValue('valid-token'); - - await expect( - service.validateTrustedDomain({ - validationToken, - workspaceTrustedDomainId: trustedDomainId, - }), - ).rejects.toThrowError( - new WorkspaceTrustedDomainException( - 'Invalid trusted domain validation token', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID, - ), - ); - }); - - it('should throw an error if the trusted domain is already validated', async () => { - const trustedDomainId = 'domain-id'; - const validationToken = 'valid-token'; - const mockTrustedDomain = { - id: trustedDomainId, - domain: 'example.com', - isValidated: true, - } as WorkspaceTrustedDomain; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(mockTrustedDomain); - - await expect( - service.validateTrustedDomain({ - validationToken, - workspaceTrustedDomainId: trustedDomainId, - }), - ).rejects.toThrowError( - new WorkspaceTrustedDomainException( - 'Trusted domain has already been validated', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_ALREADY_VALIDATED, - ), - ); - }); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts deleted file mode 100644 index f82d5226673a..000000000000 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { CustomException } from 'src/utils/custom-exception'; - -export class WorkspaceTrustedDomainException extends CustomException { - constructor(message: string, code: WorkspaceTrustedDomainExceptionCode) { - super(message, code); - } -} - -export enum WorkspaceTrustedDomainExceptionCode { - WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND = 'WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND', - WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED = 'WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED', - WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL = 'WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL', - WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID = 'WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID', - WORKSPACE_TRUSTED_DOMAIN_ALREADY_VALIDATED = 'WORKSPACE_TRUSTED_DOMAIN_ALREADY_VALIDATED', -} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts deleted file mode 100644 index 595bd16848d7..000000000000 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; - -import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; -import { WorkspaceTrustedDomainService } from 'src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service'; -import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; -import { WorkspaceTrustedDomainResolver } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver'; - -@Module({ - imports: [ - DomainManagerModule, - NestjsQueryTypeOrmModule.forFeature([WorkspaceTrustedDomain], 'core'), - ], - exports: [WorkspaceTrustedDomainService], - providers: [WorkspaceTrustedDomainService, WorkspaceTrustedDomainResolver], -}) -export class WorkspaceTrustedDomainModule {} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.validate.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.validate.ts deleted file mode 100644 index 9c1b78c72c7a..000000000000 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.validate.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { isDefined } from 'twenty-shared'; - -import { CustomException } from 'src/utils/custom-exception'; -import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; -import { - WorkspaceTrustedDomainException, - WorkspaceTrustedDomainExceptionCode, -} from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception'; - -const assertIsDefinedOrThrow = ( - trustedDomain: WorkspaceTrustedDomain | undefined | null, - exceptionToThrow: CustomException = new WorkspaceTrustedDomainException( - 'Trusted domain not found', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND, - ), -): asserts trustedDomain is WorkspaceTrustedDomain => { - if (!isDefined(trustedDomain)) { - throw exceptionToThrow; - } -}; - -export const workspaceTrustedDomainValidator: { - assertIsDefinedOrThrow: typeof assertIsDefinedOrThrow; -} = { - assertIsDefinedOrThrow, -}; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index a5bff9fe57f8..6a6b041b52e2 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -20,7 +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 { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; registerEnumType(WorkspaceActivationStatus, { name: 'WorkspaceActivationStatus', @@ -86,10 +86,10 @@ export class Workspace { featureFlags: Relation; @OneToMany( - () => WorkspaceTrustedDomain, - (trustDomain) => trustDomain.workspace, + () => ApprovedAccessDomain, + (approvedAccessDomain) => approvedAccessDomain.workspace, ) - trustDomains: Relation; + approvedAccessDomains: Relation; @Field({ nullable: true }) workspaceMembersCount: number; From ff020d809be4c85b0364d95dfbbd5034730e0f28 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 20 Feb 2025 11:51:45 +0100 Subject: [PATCH 13/22] feat(graphql): rename and restructure domain management types Renamed "TrustedDomain" to "ApprovedAccessDomain" for better clarity and consistency. Updated related types, inputs, and mutations accordingly to reflect the new naming convention. Removed redundant exports and adjusted queries and mutations to align with the changes. --- .../src/generated-metadata/graphql.ts | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index d0904013bfd0..3447343532c5 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -102,6 +102,14 @@ export type AppTokenEdge = { node: AppToken; }; +export type ApprovedAccessDomain = { + __typename?: 'ApprovedAccessDomain'; + createdAt: Scalars['DateTime']['output']; + domain: Scalars['String']['output']; + id: Scalars['UUID']['output']; + isValidated: Scalars['Boolean']['output']; +}; + export type AuthProviders = { __typename?: 'AuthProviders'; google: Scalars['Boolean']['output']; @@ -305,6 +313,11 @@ export type CreateAppTokenInput = { expiresAt: Scalars['DateTime']['input']; }; +export type CreateApprovedAccessDomainInput = { + domain: Scalars['String']['input']; + email: Scalars['String']['input']; +}; + export type CreateDraftFromWorkflowVersionInput = { /** Workflow ID */ workflowId: Scalars['String']['input']; @@ -394,11 +407,6 @@ export type CreateServerlessFunctionInput = { timeoutSeconds?: InputMaybe; }; -export type CreateTrustedDomainInput = { - domain: Scalars['String']['input']; - email: Scalars['String']['input']; -}; - export type CreateWorkflowVersionStepInput = { /** New step type */ stepType: Scalars['String']['input']; @@ -433,6 +441,10 @@ export type CustomDomainValidRecords = { records: Array; }; +export type DeleteApprovedAccessDomainInput = { + id: Scalars['String']['input']; +}; + export type DeleteOneFieldInput = { /** The id of the field to delete. */ id: Scalars['UUID']['input']; @@ -457,10 +469,6 @@ export type DeleteSsoOutput = { identityProviderId: Scalars['String']['output']; }; -export type DeleteTrustedDomainInput = { - id: Scalars['String']['input']; -}; - export type DeleteWorkflowVersionStepInput = { /** Step to delete ID */ stepId: Scalars['String']['input']; @@ -848,6 +856,7 @@ export type Mutation = { checkCustomDomainValidRecords?: Maybe; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']['output']; + createApprovedAccessDomain: ApprovedAccessDomain; createDraftFromWorkflowVersion: WorkflowVersion; createOIDCIdentityProvider: SetupSsoOutput; createOneAppToken: AppToken; @@ -858,8 +867,8 @@ export type Mutation = { createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; createWorkflowVersionStep: WorkflowAction; - createWorkspaceTrustedDomain: ApprovedAccessDomain; deactivateWorkflowVersion: Scalars['Boolean']['output']; + deleteApprovedAccessDomain: Scalars['Boolean']['output']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; deleteOneObject: Object; @@ -870,7 +879,6 @@ export type Mutation = { deleteUser: User; deleteWorkflowVersionStep: WorkflowAction; deleteWorkspaceInvitation: Scalars['String']['output']; - deleteWorkspaceTrustedDomain: Scalars['Boolean']['output']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; @@ -911,7 +919,7 @@ export type Mutation = { uploadProfilePicture: Scalars['String']['output']; uploadWorkspaceLogo: Scalars['String']['output']; userLookupAdminPanel: UserLookup; - validateWorkspaceTrustedDomain: Scalars['Boolean']['output']; + validateApprovedAccessDomain: ApprovedAccessDomain; }; @@ -950,6 +958,11 @@ export type MutationComputeStepOutputSchemaArgs = { }; +export type MutationCreateApprovedAccessDomainArgs = { + input: CreateApprovedAccessDomainInput; +}; + + export type MutationCreateDraftFromWorkflowVersionArgs = { input: CreateDraftFromWorkflowVersionInput; }; @@ -1000,13 +1013,13 @@ export type MutationCreateWorkflowVersionStepArgs = { }; -export type MutationCreateWorkspaceTrustedDomainArgs = { - input: CreateTrustedDomainInput; +export type MutationDeactivateWorkflowVersionArgs = { + workflowVersionId: Scalars['String']['input']; }; -export type MutationDeactivateWorkflowVersionArgs = { - workflowVersionId: Scalars['String']['input']; +export type MutationDeleteApprovedAccessDomainArgs = { + input: DeleteApprovedAccessDomainInput; }; @@ -1050,11 +1063,6 @@ export type MutationDeleteWorkspaceInvitationArgs = { }; -export type MutationDeleteWorkspaceTrustedDomainArgs = { - input: DeleteTrustedDomainInput; -}; - - export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; @@ -1248,8 +1256,8 @@ export type MutationUserLookupAdminPanelArgs = { }; -export type MutationValidateWorkspaceTrustedDomainArgs = { - input: ValidateTrustedDomainInput; +export type MutationValidateApprovedAccessDomainArgs = { + input: ValidateApprovedAccessDomainInput; }; export type Object = { @@ -1420,7 +1428,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; - getAllWorkspaceTrustedDomains: Array; + getAllApprovedAccessDomains: Array; getAvailablePackages: Scalars['JSON']['output']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getIndicatorHealthStatus: AdminPanelHealthServiceData; @@ -2140,17 +2148,17 @@ 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']; id: Scalars['String']['output']; }; -export type ValidateTrustedDomainInput = { - validationToken: Scalars['String']['input']; - workspaceTrustedDomainId: Scalars['String']['input']; -}; - export type WorkerQueueMetrics = { __typename?: 'WorkerQueueMetrics'; active: Scalars['Float']['output']; @@ -2284,14 +2292,6 @@ export type WorkspaceNameAndId = { id: Scalars['String']['output']; }; -export type ApprovedAccessDomain = { - __typename?: 'ApprovedAccessDomain'; - createdAt: Scalars['DateTime']['output']; - domain: Scalars['String']['output']; - id: Scalars['UUID']['output']; - isValidated: Scalars['Boolean']['output']; -}; - export type WorkspaceUrlsAndId = { __typename?: 'WorkspaceUrlsAndId'; id: Scalars['String']['output']; From 4f0a5ccf6c3e002cc4b7d0dafaea480a8cd79743 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 20 Feb 2025 12:19:36 +0100 Subject: [PATCH 14/22] refactor(security): Rename "Trusted Domains" to "Approved Access Domains" Updated all occurrences of "Trusted Domains" to "Approved Access Domains" across the application for clarity and consistency. This includes state variables, components, GraphQL queries/mutations, and UI labels. --- ...validate-approved-access-domain.email.tsx} | 6 +- packages/twenty-emails/src/index.ts | 2 +- .../twenty-front/src/generated/graphql.tsx | 233 +++++++++--------- .../modules/app/components/SettingsRoutes.tsx | 14 +- .../SettingsApprovedAccessDomainsListCard.tsx | 73 ++++++ ...tyApprovedAccessDomainRowDropdownMenu.tsx} | 40 ++- ...yApprovedAccessDomainValidationEffect.tsx} | 22 +- .../SettingsTrustedDomainsListCard.tsx | 73 ------ .../mutations/createApprovedAccessDomain.ts | 14 ++ .../mutations/createWorkspaceTrustedDomain.ts | 12 - .../mutations/deleteApprovedAccessDomain.ts | 9 + .../mutations/deleteWorkspaceTrustedDomain.ts | 7 - .../mutations/validateApprovedAccessDomain.ts | 14 ++ .../validateWorkspaceTrustedDomain.ts | 7 - .../queries/getApprovedAccessDomains.ts | 12 + .../queries/getWorkspaceTrustedDomains.ts | 12 - .../states/ApprovedAccessDomainsState.ts | 9 + .../states/WorkspaceTrustedDomainsState.ts | 9 - .../src/modules/types/SettingsPath.ts | 3 +- .../settings/security/SettingsSecurity.tsx | 4 +- ... SettingsSecurityApprovedAccessDomain.tsx} | 12 +- .../approved-access-domain.service.ts | 4 +- 22 files changed, 300 insertions(+), 291 deletions(-) rename packages/twenty-emails/src/emails/{validate-trust-domain.email.tsx => validate-approved-access-domain.email.tsx} (93%) create mode 100644 packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard.tsx rename packages/twenty-front/src/modules/settings/security/components/{workspaceTrustedDomains/SettingsSecurityTrustedDomainRowDropdownMenu.tsx => approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx} (58%) rename packages/twenty-front/src/modules/settings/security/components/{workspaceTrustedDomains/SettingsSecurityTrustedDomainValidationEffect.tsx => approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx} (56%) delete mode 100644 packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsTrustedDomainsListCard.tsx create mode 100644 packages/twenty-front/src/modules/settings/security/graphql/mutations/createApprovedAccessDomain.ts delete mode 100644 packages/twenty-front/src/modules/settings/security/graphql/mutations/createWorkspaceTrustedDomain.ts create mode 100644 packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteApprovedAccessDomain.ts delete mode 100644 packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteWorkspaceTrustedDomain.ts create mode 100644 packages/twenty-front/src/modules/settings/security/graphql/mutations/validateApprovedAccessDomain.ts delete mode 100644 packages/twenty-front/src/modules/settings/security/graphql/mutations/validateWorkspaceTrustedDomain.ts create mode 100644 packages/twenty-front/src/modules/settings/security/graphql/queries/getApprovedAccessDomains.ts delete mode 100644 packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceTrustedDomains.ts create mode 100644 packages/twenty-front/src/modules/settings/security/states/ApprovedAccessDomainsState.ts delete mode 100644 packages/twenty-front/src/modules/settings/security/states/WorkspaceTrustedDomainsState.ts rename packages/twenty-front/src/pages/settings/security/{SettingsSecurityTrustedDomain.tsx => SettingsSecurityApprovedAccessDomain.tsx} (92%) diff --git a/packages/twenty-emails/src/emails/validate-trust-domain.email.tsx b/packages/twenty-emails/src/emails/validate-approved-access-domain.email.tsx similarity index 93% rename from packages/twenty-emails/src/emails/validate-trust-domain.email.tsx rename to packages/twenty-emails/src/emails/validate-approved-access-domain.email.tsx index 77d9c9664e85..40759b453f6d 100644 --- a/packages/twenty-emails/src/emails/validate-trust-domain.email.tsx +++ b/packages/twenty-emails/src/emails/validate-approved-access-domain.email.tsx @@ -14,7 +14,7 @@ import { WhatIsTwenty } from 'src/components/WhatIsTwenty'; import { capitalize } from 'src/utils/capitalize'; import { APP_LOCALES, getImageAbsoluteURI } from 'twenty-shared'; -type SendTrustDomainValidationProps = { +type SendApprovedAccessDomainValidationProps = { link: string; domain: string; workspace: { name: string | undefined; logo: string | undefined }; @@ -27,14 +27,14 @@ type SendTrustDomainValidationProps = { locale: keyof typeof APP_LOCALES; }; -export const SendTrustDomainValidation = ({ +export const SendApprovedAccessDomainValidation = ({ link, domain, workspace, sender, serverUrl, locale, -}: SendTrustDomainValidationProps) => { +}: SendApprovedAccessDomainValidationProps) => { const workspaceLogo = workspace.logo ? getImageAbsoluteURI({ imageUrl: workspace.logo, baseUrl: serverUrl }) : null; diff --git a/packages/twenty-emails/src/index.ts b/packages/twenty-emails/src/index.ts index cd3da17239e2..07ff30d92338 100644 --- a/packages/twenty-emails/src/index.ts +++ b/packages/twenty-emails/src/index.ts @@ -4,4 +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-trust-domain.email'; +export * from './emails/validate-approved-access-domain.email'; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index c968f403acbe..058c78b08fea 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -2377,6 +2377,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; }>; @@ -2391,12 +2398,12 @@ export type CreateSamlIdentityProviderMutationVariables = Exact<{ export type CreateSamlIdentityProviderMutation = { __typename?: 'Mutation', createSAMLIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; -export type CreateWorkspaceTrustDomainMutationVariables = Exact<{ - input: CreateTrustedDomainInput; +export type DeleteApprovedAccessDomainMutationVariables = Exact<{ + input: DeleteApprovedAccessDomainInput; }>; -export type CreateWorkspaceTrustDomainMutation = { __typename?: 'Mutation', createWorkspaceTrustedDomain: { __typename?: 'WorkspaceTrustedDomain', id: any, domain: string, isValidated: boolean, createdAt: string } }; +export type DeleteApprovedAccessDomainMutation = { __typename?: 'Mutation', deleteApprovedAccessDomain: boolean }; export type DeleteSsoIdentityProviderMutationVariables = Exact<{ input: DeleteSsoInput; @@ -2405,13 +2412,6 @@ export type DeleteSsoIdentityProviderMutationVariables = Exact<{ export type DeleteSsoIdentityProviderMutation = { __typename?: 'Mutation', deleteSSOIdentityProvider: { __typename?: 'DeleteSsoOutput', identityProviderId: string } }; -export type DeleteWorkspaceTrustDomainMutationVariables = Exact<{ - input: DeleteTrustedDomainInput; -}>; - - -export type DeleteWorkspaceTrustDomainMutation = { __typename?: 'Mutation', deleteWorkspaceTrustedDomain: boolean }; - export type EditSsoIdentityProviderMutationVariables = Exact<{ input: EditSsoInput; }>; @@ -2419,22 +2419,22 @@ export type EditSsoIdentityProviderMutationVariables = Exact<{ export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; -export type ValidateWorkspaceTrustedDomainMutationVariables = Exact<{ - input: ValidateTrustedDomainInput; +export type ValidateApprovedAccessDomainMutationVariables = Exact<{ + input: ValidateApprovedAccessDomainInput; }>; -export type ValidateWorkspaceTrustedDomainMutation = { __typename?: 'Mutation', validateWorkspaceTrustedDomain: boolean }; +export type ValidateApprovedAccessDomainMutation = { __typename?: 'Mutation', validateApprovedAccessDomain: { __typename?: 'ApprovedAccessDomain', id: any, isValidated: boolean, domain: string, createdAt: string } }; -export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never; }>; +export type GetAllApprovedAccessDomainsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetSsoIdentityProvidersQuery = { __typename?: 'Query', getSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> }; +export type GetAllApprovedAccessDomainsQuery = { __typename?: 'Query', getAllApprovedAccessDomains: Array<{ __typename?: 'ApprovedAccessDomain', id: any, createdAt: string, domain: string, isValidated: boolean }> }; -export type GetAllWorkspaceTrustedDomainsQueryVariables = Exact<{ [key: string]: never; }>; +export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never; }>; -export type GetAllWorkspaceTrustedDomainsQuery = { __typename?: 'Query', getAllWorkspaceTrustedDomains: Array<{ __typename?: 'WorkspaceTrustedDomain', id: any, createdAt: string, domain: string, isValidated: boolean }> }; +export type GetSsoIdentityProvidersQuery = { __typename?: 'Query', getSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> }; export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __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 } } | null, 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 } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> }; @@ -4319,6 +4319,42 @@ export function useGetRolesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions; export type GetRolesLazyQueryHookResult = ReturnType; export type GetRolesQueryResult = Apollo.QueryResult; +export const CreateApprovedAccessDomainDocument = gql` + mutation CreateApprovedAccessDomain($input: CreateApprovedAccessDomainInput!) { + createApprovedAccessDomain(input: $input) { + id + domain + isValidated + createdAt + } +} + `; +export type CreateApprovedAccessDomainMutationFn = Apollo.MutationFunction; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateApprovedAccessDomainDocument, options); + } +export type CreateApprovedAccessDomainMutationHookResult = ReturnType; +export type CreateApprovedAccessDomainMutationResult = Apollo.MutationResult; +export type CreateApprovedAccessDomainMutationOptions = Apollo.BaseMutationOptions; export const CreateOidcIdentityProviderDocument = gql` mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) { createOIDCIdentityProvider(input: $input) { @@ -4393,42 +4429,37 @@ export function useCreateSamlIdentityProviderMutation(baseOptions?: Apollo.Mutat export type CreateSamlIdentityProviderMutationHookResult = ReturnType; export type CreateSamlIdentityProviderMutationResult = Apollo.MutationResult; export type CreateSamlIdentityProviderMutationOptions = Apollo.BaseMutationOptions; -export const CreateWorkspaceTrustDomainDocument = gql` - mutation CreateWorkspaceTrustDomain($input: CreateTrustedDomainInput!) { - createWorkspaceTrustedDomain(input: $input) { - id - domain - isValidated - createdAt - } +export const DeleteApprovedAccessDomainDocument = gql` + mutation DeleteApprovedAccessDomain($input: DeleteApprovedAccessDomainInput!) { + deleteApprovedAccessDomain(input: $input) } `; -export type CreateWorkspaceTrustDomainMutationFn = Apollo.MutationFunction; +export type DeleteApprovedAccessDomainMutationFn = Apollo.MutationFunction; /** - * __useCreateWorkspaceTrustDomainMutation__ + * __useDeleteApprovedAccessDomainMutation__ * - * To run a mutation, you first call `useCreateWorkspaceTrustDomainMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useCreateWorkspaceTrustDomainMutation` returns a tuple that includes: + * 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 [createWorkspaceTrustDomainMutation, { data, loading, error }] = useCreateWorkspaceTrustDomainMutation({ + * const [deleteApprovedAccessDomainMutation, { data, loading, error }] = useDeleteApprovedAccessDomainMutation({ * variables: { * input: // value for 'input' * }, * }); */ -export function useCreateWorkspaceTrustDomainMutation(baseOptions?: Apollo.MutationHookOptions) { +export function useDeleteApprovedAccessDomainMutation(baseOptions?: Apollo.MutationHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(CreateWorkspaceTrustDomainDocument, options); + return Apollo.useMutation(DeleteApprovedAccessDomainDocument, options); } -export type CreateWorkspaceTrustDomainMutationHookResult = ReturnType; -export type CreateWorkspaceTrustDomainMutationResult = Apollo.MutationResult; -export type CreateWorkspaceTrustDomainMutationOptions = Apollo.BaseMutationOptions; +export type DeleteApprovedAccessDomainMutationHookResult = ReturnType; +export type DeleteApprovedAccessDomainMutationResult = Apollo.MutationResult; +export type DeleteApprovedAccessDomainMutationOptions = Apollo.BaseMutationOptions; export const DeleteSsoIdentityProviderDocument = gql` mutation DeleteSSOIdentityProvider($input: DeleteSsoInput!) { deleteSSOIdentityProvider(input: $input) { @@ -4462,37 +4493,6 @@ export function useDeleteSsoIdentityProviderMutation(baseOptions?: Apollo.Mutati export type DeleteSsoIdentityProviderMutationHookResult = ReturnType; export type DeleteSsoIdentityProviderMutationResult = Apollo.MutationResult; export type DeleteSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions; -export const DeleteWorkspaceTrustDomainDocument = gql` - mutation DeleteWorkspaceTrustDomain($input: DeleteTrustedDomainInput!) { - deleteWorkspaceTrustedDomain(input: $input) -} - `; -export type DeleteWorkspaceTrustDomainMutationFn = Apollo.MutationFunction; - -/** - * __useDeleteWorkspaceTrustDomainMutation__ - * - * To run a mutation, you first call `useDeleteWorkspaceTrustDomainMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useDeleteWorkspaceTrustDomainMutation` 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 [deleteWorkspaceTrustDomainMutation, { data, loading, error }] = useDeleteWorkspaceTrustDomainMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function useDeleteWorkspaceTrustDomainMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(DeleteWorkspaceTrustDomainDocument, options); - } -export type DeleteWorkspaceTrustDomainMutationHookResult = ReturnType; -export type DeleteWorkspaceTrustDomainMutationResult = Apollo.MutationResult; -export type DeleteWorkspaceTrustDomainMutationOptions = Apollo.BaseMutationOptions; export const EditSsoIdentityProviderDocument = gql` mutation EditSSOIdentityProvider($input: EditSsoInput!) { editSSOIdentityProvider(input: $input) { @@ -4530,112 +4530,117 @@ export function useEditSsoIdentityProviderMutation(baseOptions?: Apollo.Mutation export type EditSsoIdentityProviderMutationHookResult = ReturnType; export type EditSsoIdentityProviderMutationResult = Apollo.MutationResult; export type EditSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions; -export const ValidateWorkspaceTrustedDomainDocument = gql` - mutation ValidateWorkspaceTrustedDomain($input: ValidateTrustedDomainInput!) { - validateWorkspaceTrustedDomain(input: $input) +export const ValidateApprovedAccessDomainDocument = gql` + mutation ValidateApprovedAccessDomain($input: ValidateApprovedAccessDomainInput!) { + validateApprovedAccessDomain(input: $input) { + id + isValidated + domain + createdAt + } } `; -export type ValidateWorkspaceTrustedDomainMutationFn = Apollo.MutationFunction; +export type ValidateApprovedAccessDomainMutationFn = Apollo.MutationFunction; /** - * __useValidateWorkspaceTrustedDomainMutation__ + * __useValidateApprovedAccessDomainMutation__ * - * To run a mutation, you first call `useValidateWorkspaceTrustedDomainMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useValidateWorkspaceTrustedDomainMutation` returns a tuple that includes: + * 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 [validateWorkspaceTrustedDomainMutation, { data, loading, error }] = useValidateWorkspaceTrustedDomainMutation({ + * const [validateApprovedAccessDomainMutation, { data, loading, error }] = useValidateApprovedAccessDomainMutation({ * variables: { * input: // value for 'input' * }, * }); */ -export function useValidateWorkspaceTrustedDomainMutation(baseOptions?: Apollo.MutationHookOptions) { +export function useValidateApprovedAccessDomainMutation(baseOptions?: Apollo.MutationHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(ValidateWorkspaceTrustedDomainDocument, options); + return Apollo.useMutation(ValidateApprovedAccessDomainDocument, options); } -export type ValidateWorkspaceTrustedDomainMutationHookResult = ReturnType; -export type ValidateWorkspaceTrustedDomainMutationResult = Apollo.MutationResult; -export type ValidateWorkspaceTrustedDomainMutationOptions = Apollo.BaseMutationOptions; -export const GetSsoIdentityProvidersDocument = gql` - query GetSSOIdentityProviders { - getSSOIdentityProviders { - type +export type ValidateApprovedAccessDomainMutationHookResult = ReturnType; +export type ValidateApprovedAccessDomainMutationResult = Apollo.MutationResult; +export type ValidateApprovedAccessDomainMutationOptions = Apollo.BaseMutationOptions; +export const GetAllApprovedAccessDomainsDocument = gql` + query GetAllApprovedAccessDomains { + getAllApprovedAccessDomains { id - name - issuer - status + createdAt + domain + isValidated } } `; /** - * __useGetSsoIdentityProvidersQuery__ + * __useGetAllApprovedAccessDomainsQuery__ * - * To run a query within a React component, call `useGetSsoIdentityProvidersQuery` and pass it any options that fit your needs. - * When your component renders, `useGetSsoIdentityProvidersQuery` returns an object from Apollo Client that contains loading, error, and data properties + * To run a query within a React component, call `useGetAllApprovedAccessDomainsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetAllApprovedAccessDomainsQuery` 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 } = useGetSsoIdentityProvidersQuery({ + * const { data, loading, error } = useGetAllApprovedAccessDomainsQuery({ * variables: { * }, * }); */ -export function useGetSsoIdentityProvidersQuery(baseOptions?: Apollo.QueryHookOptions) { +export function useGetAllApprovedAccessDomainsQuery(baseOptions?: Apollo.QueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(GetSsoIdentityProvidersDocument, options); + return Apollo.useQuery(GetAllApprovedAccessDomainsDocument, options); } -export function useGetSsoIdentityProvidersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { +export function useGetAllApprovedAccessDomainsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(GetSsoIdentityProvidersDocument, options); + return Apollo.useLazyQuery(GetAllApprovedAccessDomainsDocument, options); } -export type GetSsoIdentityProvidersQueryHookResult = ReturnType; -export type GetSsoIdentityProvidersLazyQueryHookResult = ReturnType; -export type GetSsoIdentityProvidersQueryResult = Apollo.QueryResult; -export const GetAllWorkspaceTrustedDomainsDocument = gql` - query GetAllWorkspaceTrustedDomains { - getAllWorkspaceTrustedDomains { +export type GetAllApprovedAccessDomainsQueryHookResult = ReturnType; +export type GetAllApprovedAccessDomainsLazyQueryHookResult = ReturnType; +export type GetAllApprovedAccessDomainsQueryResult = Apollo.QueryResult; +export const GetSsoIdentityProvidersDocument = gql` + query GetSSOIdentityProviders { + getSSOIdentityProviders { + type id - createdAt - domain - isValidated + name + issuer + status } } `; /** - * __useGetAllWorkspaceTrustedDomainsQuery__ + * __useGetSsoIdentityProvidersQuery__ * - * To run a query within a React component, call `useGetAllWorkspaceTrustedDomainsQuery` and pass it any options that fit your needs. - * When your component renders, `useGetAllWorkspaceTrustedDomainsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * To run a query within a React component, call `useGetSsoIdentityProvidersQuery` and pass it any options that fit your needs. + * When your component renders, `useGetSsoIdentityProvidersQuery` 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 } = useGetAllWorkspaceTrustedDomainsQuery({ + * const { data, loading, error } = useGetSsoIdentityProvidersQuery({ * variables: { * }, * }); */ -export function useGetAllWorkspaceTrustedDomainsQuery(baseOptions?: Apollo.QueryHookOptions) { +export function useGetSsoIdentityProvidersQuery(baseOptions?: Apollo.QueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(GetAllWorkspaceTrustedDomainsDocument, options); + return Apollo.useQuery(GetSsoIdentityProvidersDocument, options); } -export function useGetAllWorkspaceTrustedDomainsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { +export function useGetSsoIdentityProvidersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(GetAllWorkspaceTrustedDomainsDocument, options); + return Apollo.useLazyQuery(GetSsoIdentityProvidersDocument, options); } -export type GetAllWorkspaceTrustedDomainsQueryHookResult = ReturnType; -export type GetAllWorkspaceTrustedDomainsLazyQueryHookResult = ReturnType; -export type GetAllWorkspaceTrustedDomainsQueryResult = Apollo.QueryResult; +export type GetSsoIdentityProvidersQueryHookResult = ReturnType; +export type GetSsoIdentityProvidersLazyQueryHookResult = ReturnType; +export type GetSsoIdentityProvidersQueryResult = Apollo.QueryResult; export const DeleteUserAccountDocument = gql` mutation DeleteUserAccount { deleteUser { diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 5ced77987e80..4d549ccad4c8 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -234,10 +234,10 @@ const SettingsSecuritySSOIdentifyProvider = lazy(() => ), ); -const SettingsSecurityTrustedDomain = lazy(() => - import('~/pages/settings/security/SettingsSecurityTrustedDomain').then( +const SettingsSecurityApprovedAccessDomain = lazy(() => + import('~/pages/settings/security/SettingsSecurityApprovedAccessDomain').then( (module) => ({ - default: module.SettingsSecurityTrustedDomain, + default: module.SettingsSecurityApprovedAccessDomain, }), ), ); @@ -417,12 +417,8 @@ export const SettingsRoutes = ({ element={} /> } - /> - } + path={SettingsPath.NewApprovedAccessDomain} + element={} /> {isAdminPageEnabled && ( diff --git a/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard.tsx b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard.tsx new file mode 100644 index 000000000000..1c81c975bc78 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard.tsx @@ -0,0 +1,73 @@ +import { Link, useNavigate } from 'react-router-dom'; + +import { SettingsPath } from '@/types/SettingsPath'; + +import { SettingsCard } from '@/settings/components/SettingsCard'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import styled from '@emotion/styled'; +import { useLingui } from '@lingui/react/macro'; +import { useRecoilState } from 'recoil'; +import { IconAt, IconMailCog } from 'twenty-ui'; +import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; +import { SettingsListCard } from '@/settings/components/SettingsListCard'; +import { approvedAccessDomainsState } from '@/settings/security/states/ApprovedAccessDomainsState'; +import { SettingsSecurityApprovedAccessDomainRowDropdownMenu } from '@/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu'; +import { SettingsSecurityApprovedAccessDomainValidationEffect } from '@/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect'; +import { useGetAllApprovedAccessDomainsQuery } 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 } = useGetAllApprovedAccessDomainsQuery({ + fetchPolicy: 'network-only', + onCompleted: (data) => { + setApprovedAccessDomains(data?.getAllApprovedAccessDomains ?? []); + }, + onError: (error: Error) => { + enqueueSnackBar(error.message, { + variant: SnackBarVariant.Error, + }); + }, + }); + + return loading || !approvedAccessDomains.length ? ( + + } + /> + + ) : ( + <> + + + `${approvedAccessDomain.domain} - ${approvedAccessDomain.createdAt}` + } + RowIcon={IconAt} + RowRightComponent={({ item: approvedAccessDomain }) => ( + + )} + hasFooter + footerButtonLabel="Add Approved Access Domain" + onFooterButtonClick={() => + navigate(getSettingsPath(SettingsPath.NewApprovedAccessDomain)) + } + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainRowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx similarity index 58% rename from packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainRowDropdownMenu.tsx rename to packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx index 9b758ec4df0d..576df250292b 100644 --- a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainRowDropdownMenu.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx @@ -12,47 +12,45 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { UnwrapRecoilValue, useSetRecoilState } from 'recoil'; import { isDefined } from 'twenty-shared'; -import { useDeleteWorkspaceTrustDomainMutation } from '~/generated/graphql'; -import { workspaceTrustedDomainsState } from '@/settings/security/states/WorkspaceTrustedDomainsState'; +import { useDeleteApprovedAccessDomainMutation } from '~/generated/graphql'; +import { approvedAccessDomainsState } from '@/settings/security/states/ApprovedAccessDomainsState'; -type SettingsSecurityTrustedDomainRowDropdownMenuProps = { - workspaceTrustedDomain: UnwrapRecoilValue< - typeof workspaceTrustedDomainsState - >[0]; +type SettingsSecurityApprovedAccessDomainRowDropdownMenuProps = { + approvedAccessDomain: UnwrapRecoilValue[0]; }; -export const SettingsSecurityTrustedDomainRowDropdownMenu = ({ - workspaceTrustedDomain, -}: SettingsSecurityTrustedDomainRowDropdownMenuProps) => { - const dropdownId = `settings-account-row-${workspaceTrustedDomain.id}`; +export const SettingsSecurityApprovedAccessDomainRowDropdownMenu = ({ + approvedAccessDomain, +}: SettingsSecurityApprovedAccessDomainRowDropdownMenuProps) => { + const dropdownId = `settings-account-row-${approvedAccessDomain.id}`; - const setWorkspaceTrustedDomains = useSetRecoilState( - workspaceTrustedDomainsState, + const setApprovedAccessDomains = useSetRecoilState( + approvedAccessDomainsState, ); const { enqueueSnackBar } = useSnackBar(); const { closeDropdown } = useDropdown(dropdownId); - const [deleteWorkspaceTrustDomain] = useDeleteWorkspaceTrustDomainMutation(); + const [deleteApprovedAccessDomain] = useDeleteApprovedAccessDomainMutation(); - const handleDeleteWorkspaceTrustedDomain = async () => { - const result = await deleteWorkspaceTrustDomain({ + const handleDeleteApprovedAccessDomain = async () => { + const result = await deleteApprovedAccessDomain({ variables: { input: { - id: workspaceTrustedDomain.id, + id: approvedAccessDomain.id, }, }, onCompleted: () => { - setWorkspaceTrustedDomains((workspaceTrustedDomains) => { - return workspaceTrustedDomains.filter( - (trustedDomain) => trustedDomain.id !== workspaceTrustedDomain.id, + setApprovedAccessDomains((approvedAccessDomains) => { + return approvedAccessDomains.filter( + ({ id }) => id !== approvedAccessDomain.id, ); }); }, }); if (isDefined(result.errors)) { - enqueueSnackBar('Error deleting workspace trust domain', { + enqueueSnackBar('Error deleting approved access domain', { variant: SnackBarVariant.Error, duration: 2000, }); @@ -75,7 +73,7 @@ export const SettingsSecurityTrustedDomainRowDropdownMenu = ({ LeftIcon={IconTrash} text="Delete" onClick={() => { - handleDeleteWorkspaceTrustedDomain(); + handleDeleteApprovedAccessDomain(); closeDropdown(); }} /> diff --git a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainValidationEffect.tsx b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx similarity index 56% rename from packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainValidationEffect.tsx rename to packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx index 1c440a7a8b1b..cbce19f83674 100644 --- a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainValidationEffect.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx @@ -1,40 +1,40 @@ import { useEffect } from 'react'; import { isDefined } from 'twenty-shared'; -import { useValidateWorkspaceTrustedDomainMutation } from '~/generated/graphql'; +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 SettingsSecurityTrustedDomainValidationEffect = () => { - const [validateWorkspaceTrustedDomainMutation] = - useValidateWorkspaceTrustedDomainMutation(); +export const SettingsSecurityApprovedAccessDomainValidationEffect = () => { + const [validateApprovedAccessDomainMutation] = + useValidateApprovedAccessDomainMutation(); const { enqueueSnackBar } = useSnackBar(); const [searchParams] = useSearchParams(); - const workspaceTrustedDomainId = searchParams.get('wtdId'); + const approvedAccessDomainId = searchParams.get('wtdId'); const validationToken = searchParams.get('validationToken'); useEffect(() => { - if (isDefined(validationToken) && isDefined(workspaceTrustedDomainId)) { - validateWorkspaceTrustedDomainMutation({ + if (isDefined(validationToken) && isDefined(approvedAccessDomainId)) { + validateApprovedAccessDomainMutation({ variables: { input: { validationToken, - workspaceTrustedDomainId, + approvedAccessDomainId, }, }, onCompleted: () => { - enqueueSnackBar('Trusted domain validated', { + enqueueSnackBar('Approved access domain validated', { variant: SnackBarVariant.Success, }); }, onError: () => { - enqueueSnackBar('Error validating trusted domain', { + enqueueSnackBar('Error validating approved access domain', { variant: SnackBarVariant.Error, }); }, }); } - // Validate trusted domain only needs to run once at mount + // Validate approved access domain only needs to run once at mount // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsTrustedDomainsListCard.tsx b/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsTrustedDomainsListCard.tsx deleted file mode 100644 index f7888dfb05b0..000000000000 --- a/packages/twenty-front/src/modules/settings/security/components/workspaceTrustedDomains/SettingsTrustedDomainsListCard.tsx +++ /dev/null @@ -1,73 +0,0 @@ -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 { useGetAllWorkspaceTrustedDomainsQuery } from '~/generated/graphql'; -import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; -import { SettingsListCard } from '@/settings/components/SettingsListCard'; -import { workspaceTrustedDomainsState } from '@/settings/security/states/WorkspaceTrustedDomainsState'; -import { SettingsSecurityTrustedDomainRowDropdownMenu } from '@/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainRowDropdownMenu'; -import { SettingsSecurityTrustedDomainValidationEffect } from '@/settings/security/components/workspaceTrustedDomains/SettingsSecurityTrustedDomainValidationEffect'; - -const StyledLink = styled(Link)` - text-decoration: none; -`; - -export const SettingsTrustedDomainsListCard = () => { - const { enqueueSnackBar } = useSnackBar(); - const navigate = useNavigate(); - const { t } = useLingui(); - - const [workspaceTrustedDomains, setWorkspaceTrustedDomains] = useRecoilState( - workspaceTrustedDomainsState, - ); - - const { loading } = useGetAllWorkspaceTrustedDomainsQuery({ - fetchPolicy: 'network-only', - onCompleted: (data) => { - setWorkspaceTrustedDomains(data?.getAllWorkspaceTrustedDomains ?? []); - }, - onError: (error: Error) => { - enqueueSnackBar(error.message, { - variant: SnackBarVariant.Error, - }); - }, - }); - - return loading || !workspaceTrustedDomains.length ? ( - - } - /> - - ) : ( - <> - - - `${workspaceTrustedDomain.domain} - ${workspaceTrustedDomain.createdAt}` - } - RowIcon={IconAt} - RowRightComponent={({ item: workspaceTrustedDomain }) => ( - - )} - hasFooter - footerButtonLabel="Add Trusted Domain" - onFooterButtonClick={() => - navigate(getSettingsPath(SettingsPath.NewTrustedDomain)) - } - /> - - ); -}; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/createApprovedAccessDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createApprovedAccessDomain.ts new file mode 100644 index 000000000000..6d23aa3a3325 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createApprovedAccessDomain.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const CREATE_APPROVED_ACCESS_DOMAIN = gql` + mutation CreateApprovedAccessDomain( + $input: CreateApprovedAccessDomainInput! + ) { + createApprovedAccessDomain(input: $input) { + id + domain + isValidated + createdAt + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/createWorkspaceTrustedDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createWorkspaceTrustedDomain.ts deleted file mode 100644 index 75a0c87150e9..000000000000 --- a/packages/twenty-front/src/modules/settings/security/graphql/mutations/createWorkspaceTrustedDomain.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { gql } from '@apollo/client'; - -export const CREATE_WORKSPACE_TRUSTED_DOMAIN = gql` - mutation CreateWorkspaceTrustDomain($input: CreateTrustedDomainInput!) { - createWorkspaceTrustedDomain(input: $input) { - id - domain - isValidated - createdAt - } - } -`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteApprovedAccessDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteApprovedAccessDomain.ts new file mode 100644 index 000000000000..a410bbed2152 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteApprovedAccessDomain.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const DELETE_APPROVED_ACCESS_DOMAIN = gql` + mutation DeleteApprovedAccessDomain( + $input: DeleteApprovedAccessDomainInput! + ) { + deleteApprovedAccessDomain(input: $input) + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteWorkspaceTrustedDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteWorkspaceTrustedDomain.ts deleted file mode 100644 index 9adba92bf86f..000000000000 --- a/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteWorkspaceTrustedDomain.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { gql } from '@apollo/client'; - -export const DELETE_WORKSPACE_TRUSTED_DOMAIN = gql` - mutation DeleteWorkspaceTrustDomain($input: DeleteTrustedDomainInput!) { - deleteWorkspaceTrustedDomain(input: $input) - } -`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/validateApprovedAccessDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/validateApprovedAccessDomain.ts new file mode 100644 index 000000000000..76b910dc2de4 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/validateApprovedAccessDomain.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const VALIDATE_APPROVED_ACCESS_DOMAIN = gql` + mutation ValidateApprovedAccessDomain( + $input: ValidateApprovedAccessDomainInput! + ) { + validateApprovedAccessDomain(input: $input) { + id + isValidated + domain + createdAt + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/validateWorkspaceTrustedDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/validateWorkspaceTrustedDomain.ts deleted file mode 100644 index 01f988769198..000000000000 --- a/packages/twenty-front/src/modules/settings/security/graphql/mutations/validateWorkspaceTrustedDomain.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { gql } from '@apollo/client'; - -export const VALIDATE_WORKSPACE_TRUSTED_DOMAIN = gql` - mutation ValidateWorkspaceTrustedDomain($input: ValidateTrustedDomainInput!) { - validateWorkspaceTrustedDomain(input: $input) - } -`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/queries/getApprovedAccessDomains.ts b/packages/twenty-front/src/modules/settings/security/graphql/queries/getApprovedAccessDomains.ts new file mode 100644 index 000000000000..7e4967f7928e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/queries/getApprovedAccessDomains.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +export const GET_ALL_APPROVED_ACCESS_DOMAINS = gql` + query GetAllApprovedAccessDomains { + getAllApprovedAccessDomains { + id + createdAt + domain + isValidated + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceTrustedDomains.ts b/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceTrustedDomains.ts deleted file mode 100644 index 0278cf6867eb..000000000000 --- a/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceTrustedDomains.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { gql } from '@apollo/client'; - -export const GET_ALL_WORKSPACE_TRUSTED_DOMAINS = gql` - query GetAllWorkspaceTrustedDomains { - getAllWorkspaceTrustedDomains { - id - createdAt - domain - isValidated - } - } -`; diff --git a/packages/twenty-front/src/modules/settings/security/states/ApprovedAccessDomainsState.ts b/packages/twenty-front/src/modules/settings/security/states/ApprovedAccessDomainsState.ts new file mode 100644 index 000000000000..ca0ddc79f6fa --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/states/ApprovedAccessDomainsState.ts @@ -0,0 +1,9 @@ +import { createState } from '@ui/utilities/state/utils/createState'; +import { ApprovedAccessDomain } from '~/generated/graphql'; + +export const approvedAccessDomainsState = createState< + Omit[] +>({ + key: 'ApprovedAccessDomainsState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/settings/security/states/WorkspaceTrustedDomainsState.ts b/packages/twenty-front/src/modules/settings/security/states/WorkspaceTrustedDomainsState.ts deleted file mode 100644 index 17b76027d931..000000000000 --- a/packages/twenty-front/src/modules/settings/security/states/WorkspaceTrustedDomainsState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createState } from '@ui/utilities/state/utils/createState'; -import { WorkspaceTrustedDomain } from '~/generated/graphql'; - -export const workspaceTrustedDomainsState = createState< - Omit[] ->({ - key: 'WorkspaceTrustedDomainState', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts index f3ee9c63faf9..6cebac0d7a62 100644 --- a/packages/twenty-front/src/modules/types/SettingsPath.ts +++ b/packages/twenty-front/src/modules/types/SettingsPath.ts @@ -29,8 +29,7 @@ export enum SettingsPath { IntegrationNewDatabaseConnection = 'integrations/:databaseKey/new', Security = 'security', NewSSOIdentityProvider = 'security/sso/new', - NewTrustedDomain = 'security/trusted-domain/new', - EditTrustedDomain = 'security/trusted-domain/edit/:trustedDomainId', + NewApprovedAccessDomain = 'security/approved-access-domain/new', Webhooks = 'webhooks', DevelopersNewWebhook = 'developers/webhooks/new', DevelopersNewWebhookDetail = 'developers/webhooks/:webhookId', diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx index 80cdfa4d7317..9787a41329d6 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -9,7 +9,7 @@ import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/co import { SettingsPath } from '@/types/SettingsPath'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; -import { SettingsTrustedDomainsListCard } from '@/settings/security/components/workspaceTrustedDomains/SettingsTrustedDomainsListCard'; +import { SettingsApprovedAccessDomainsListCard } from '@/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard'; const StyledContainer = styled.div` width: 100%; @@ -63,7 +63,7 @@ export const SettingsSecurity = () => { title={t`Approved Email Domain`} description={t`Anyone with an email address at these domains is allowed to sign up for this workspace.`} /> - +
diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurityTrustedDomain.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx similarity index 92% rename from packages/twenty-front/src/pages/settings/security/SettingsSecurityTrustedDomain.tsx rename to packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx index 59ff77a42a79..6562e1e25a11 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurityTrustedDomain.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx @@ -13,16 +13,16 @@ import { TextInput } from '@/ui/input/components/TextInput'; import { z } from 'zod'; import { H2Title, Section } from 'twenty-ui'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; -import { useCreateWorkspaceTrustDomainMutation } from '~/generated/graphql'; +import { useCreateApprovedAccessDomainMutation } from '~/generated/graphql'; -export const SettingsSecurityTrustedDomain = () => { +export const SettingsSecurityApprovedAccessDomain = () => { const navigate = useNavigateSettings(); const { t } = useLingui(); const { enqueueSnackBar } = useSnackBar(); - const [createWorkspaceTrustDomain] = useCreateWorkspaceTrustDomainMutation(); + const [createApprovedAccessDomain] = useCreateApprovedAccessDomainMutation(); const formConfig = useForm<{ domain: string; email: string }>({ mode: 'onChange', @@ -44,7 +44,7 @@ export const SettingsSecurityTrustedDomain = () => { const handleSave = async () => { try { - createWorkspaceTrustDomain({ + createApprovedAccessDomain({ variables: { input: { domain: formConfig.getValues('domain'), @@ -75,7 +75,7 @@ export const SettingsSecurityTrustedDomain = () => { return ( { children: Security, href: getSettingsPath(SettingsPath.Security), }, - { children: New Trusted Domain }, + { children: New Approved Access Domain }, ]} > diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts index 2c473aadf00d..767259b5c8f8 100644 --- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts @@ -6,7 +6,7 @@ import crypto from 'crypto'; import { render } from '@react-email/render'; import { Repository } from 'typeorm'; import { APP_LOCALES } from 'twenty-shared'; -import { SendTrustDomainValidation } from 'twenty-emails'; +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'; @@ -60,7 +60,7 @@ export class ApprovedAccessDomainService { }, }); - const emailTemplate = SendTrustDomainValidation({ + const emailTemplate = SendApprovedAccessDomainValidation({ link: link.toString(), workspace: { name: workspace.displayName, logo: workspace.logo }, domain: approvedAccessDomain.domain, From 800c0659ad4adc2c97a7070dab4dd4c6ced8cfa5 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 20 Feb 2025 16:36:19 +0100 Subject: [PATCH 15/22] refactor(settings/security): remove redundant TextInput labels Removed unused "Domain" and "Email" labels from TextInput components. This cleanup reduces unnecessary props and improves code readability. --- .../settings/security/SettingsSecurityApprovedAccessDomain.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx index 6562e1e25a11..3a32ca14ade5 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx @@ -104,7 +104,6 @@ export const SettingsSecurityApprovedAccessDomain = () => { render={({ field: { onChange, value } }) => ( { onChange(domain); @@ -126,7 +125,6 @@ export const SettingsSecurityApprovedAccessDomain = () => { render={({ field: { onChange, value } }) => ( Date: Thu, 20 Feb 2025 16:48:23 +0100 Subject: [PATCH 16/22] refactor: inline domain schema and remove redundant import The domain validation schema is now defined inline where used instead of being imported from a separate file. This simplifies the codebase by removing unnecessary indirection and eliminating the domainSchema file altogether. --- .../validation-schemas/domainSchema.ts | 13 ---------- .../SettingsSecurityApprovedAccessDomain.tsx | 25 ++++++++++++++----- .../settings/workspace/SettingsDomain.tsx | 13 ++++++++-- 3 files changed, 30 insertions(+), 21 deletions(-) delete mode 100644 packages/twenty-front/src/modules/settings/security/validation-schemas/domainSchema.ts diff --git a/packages/twenty-front/src/modules/settings/security/validation-schemas/domainSchema.ts b/packages/twenty-front/src/modules/settings/security/validation-schemas/domainSchema.ts deleted file mode 100644 index 304bc2f800ed..000000000000 --- a/packages/twenty-front/src/modules/settings/security/validation-schemas/domainSchema.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from 'zod'; -import { t } from '@lingui/macro'; - -export const domainSchema = 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])$/, - { - // eslint-disable-next-line lingui/t-call-in-function - 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.`, - }, - ) - .max(256); diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx index 3a32ca14ade5..f3b5b153c6f4 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx @@ -7,7 +7,6 @@ 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 { domainSchema } from '@/settings/security/validation-schemas/domainSchema'; import { Trans, useLingui } from '@lingui/react/macro'; import { TextInput } from '@/ui/input/components/TextInput'; import { z } from 'zod'; @@ -29,8 +28,18 @@ export const SettingsSecurityApprovedAccessDomain = () => { resolver: zodResolver( z .object({ - domain: domainSchema, - email: z.string().min(1), + 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(), ), @@ -44,6 +53,9 @@ export const SettingsSecurityApprovedAccessDomain = () => { const handleSave = async () => { try { + if (!formConfig.formState.isValid) { + return; + } createApprovedAccessDomain({ variables: { input: { @@ -78,7 +90,6 @@ export const SettingsSecurityApprovedAccessDomain = () => { title="New Approved Access Domain" actionButton={ navigate(SettingsPath.Security)} onSave={handleSave} /> @@ -101,7 +112,7 @@ export const SettingsSecurityApprovedAccessDomain = () => { ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( { }} fullWidth placeholder="yourdomain.com" + error={error?.message} /> )} /> @@ -122,12 +134,13 @@ export const SettingsSecurityApprovedAccessDomain = () => { ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( )} /> diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx index 9c4f49fba87a..107bf63acea4 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx @@ -26,7 +26,6 @@ import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsCustomDomainEffect } from '~/pages/settings/workspace/SettingsCustomDomainEffect'; import { isDefined } from 'twenty-shared'; -import { domainSchema } from '@/settings/security/validation-schemas/domainSchema'; export const SettingsDomain = () => { const navigate = useNavigateSettings(); @@ -41,7 +40,17 @@ export const SettingsDomain = () => { .regex(/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/, { message: t`Use letter, number and dash only. Start and finish with a letter or a number`, }), - customDomain: domainSchema.optional().or(z.literal('')), + customDomain: 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) + .optional() + .or(z.literal('')), }) .required(); From fb1aba18835afdf579dfe40d7839865070ec54d9 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 20 Feb 2025 17:00:56 +0100 Subject: [PATCH 17/22] fix(settings): update form submission mode to onSubmit Changed the form validation mode from onChange to onSubmit for better user experience and to align with existing submission patterns. Updated the save button handler to use the form's submit handler to ensure proper data validation before saving. --- .../security/SettingsSecurityApprovedAccessDomain.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx index f3b5b153c6f4..9934eb959553 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx @@ -24,7 +24,7 @@ export const SettingsSecurityApprovedAccessDomain = () => { const [createApprovedAccessDomain] = useCreateApprovedAccessDomainMutation(); const formConfig = useForm<{ domain: string; email: string }>({ - mode: 'onChange', + mode: 'onSubmit', resolver: zodResolver( z .object({ @@ -91,7 +91,7 @@ export const SettingsSecurityApprovedAccessDomain = () => { actionButton={ navigate(SettingsPath.Security)} - onSave={handleSave} + onSave={formConfig.handleSubmit(handleSave)} /> } links={[ From e172a83e2c7dc8d03fa6eaa957a8362c19366593 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 20 Feb 2025 17:02:37 +0100 Subject: [PATCH 18/22] chore(dependencies): remove unused Lingui macro packages Removed @lingui/macro and related 5.2.0 versions from dependencies and yarn.lock. This cleanup ensures only necessary Lingui packages are retained, aligning with the used 5.1.2 versions. --- package.json | 1 - yarn.lock | 67 ---------------------------------------------------- 2 files changed, 68 deletions(-) diff --git a/package.json b/package.json index 67bd5eaa77d0..79261e146954 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "@linaria/core": "^6.2.0", "@linaria/react": "^6.2.1", "@lingui/core": "^5.1.2", - "@lingui/macro": "^5.1.2", "@lingui/react": "^5.1.2", "@mdx-js/react": "^3.0.0", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/yarn.lock b/yarn.lock index 0eb312716ec0..d49f0bb1b271 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7530,25 +7530,6 @@ __metadata: languageName: node linkType: hard -"@lingui/core@npm:5.2.0": - version: 5.2.0 - resolution: "@lingui/core@npm:5.2.0" - dependencies: - "@babel/runtime": "npm:^7.20.13" - "@lingui/message-utils": "npm:5.2.0" - unraw: "npm:^3.0.0" - peerDependencies: - "@lingui/babel-plugin-lingui-macro": 5.2.0 - babel-plugin-macros: 2 || 3 - peerDependenciesMeta: - "@lingui/babel-plugin-lingui-macro": - optional: true - babel-plugin-macros: - optional: true - checksum: 10c0/ca2d95c758b352eab44c2080485515cca7329d95ecbf700fe8bdc325b220eefe53ec8356e4f90ab638a31e2a466afa92e24c5644c04fb093e02048d2bf49ea38 - languageName: node - linkType: hard - "@lingui/core@npm:^5.1.2": version: 5.1.2 resolution: "@lingui/core@npm:5.1.2" @@ -7587,34 +7568,6 @@ __metadata: languageName: node linkType: hard -"@lingui/macro@npm:^5.1.2": - version: 5.2.0 - resolution: "@lingui/macro@npm:5.2.0" - dependencies: - "@lingui/core": "npm:5.2.0" - "@lingui/react": "npm:5.2.0" - peerDependencies: - "@lingui/babel-plugin-lingui-macro": 5.2.0 - babel-plugin-macros: 2 || 3 - peerDependenciesMeta: - "@lingui/babel-plugin-lingui-macro": - optional: true - babel-plugin-macros: - optional: true - checksum: 10c0/cad57d8b46e54f110e5442d21415be4eaca89993f779133b3f1d9eef9c0662594345346a1853bb2104cd00b80628eba6b7397ce1672e744576c5b0d9dd5951c0 - languageName: node - linkType: hard - -"@lingui/message-utils@npm:5.2.0": - version: 5.2.0 - resolution: "@lingui/message-utils@npm:5.2.0" - dependencies: - "@messageformat/parser": "npm:^5.0.0" - js-sha256: "npm:^0.10.1" - checksum: 10c0/eab1e817d1e8ff201ae1b17820c4d066ca8bf5e4fbf6b8626ba890b9309374410a2a598d067b68e3a7fbad728b86763b96fa5fb43495a273d1704a80ad8a88c8 - languageName: node - linkType: hard - "@lingui/message-utils@npm:^5.1.2": version: 5.1.2 resolution: "@lingui/message-utils@npm:5.1.2" @@ -7625,25 +7578,6 @@ __metadata: languageName: node linkType: hard -"@lingui/react@npm:5.2.0": - version: 5.2.0 - resolution: "@lingui/react@npm:5.2.0" - dependencies: - "@babel/runtime": "npm:^7.20.13" - "@lingui/core": "npm:5.2.0" - peerDependencies: - "@lingui/babel-plugin-lingui-macro": 5.2.0 - babel-plugin-macros: 2 || 3 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - "@lingui/babel-plugin-lingui-macro": - optional: true - babel-plugin-macros: - optional: true - checksum: 10c0/429fb6b262f9328366dda064220d725725fe3b527382e56d1e8789a26ede6ff16409361261204a9ab257f7ad0e5bb5ed031bb394a9b409468818437ceed4608a - languageName: node - linkType: hard - "@lingui/react@npm:^5.1.2": version: 5.1.2 resolution: "@lingui/react@npm:5.1.2" @@ -47184,7 +47118,6 @@ __metadata: "@linaria/react": "npm:^6.2.1" "@lingui/cli": "npm:^5.1.2" "@lingui/core": "npm:^5.1.2" - "@lingui/macro": "npm:^5.1.2" "@lingui/react": "npm:^5.1.2" "@lingui/swc-plugin": "npm:^5.1.0" "@lingui/vite-plugin": "npm:^5.1.2" From c603e7abdbfc0bbd0fe531bd2977c387d2949107 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 20 Feb 2025 17:05:12 +0100 Subject: [PATCH 19/22] fix(security settings): update dropdown ID for clarity Corrected the generated dropdown ID to use a more descriptive naming convention for approved access domains. This prevents potential confusion and improves code readability. No functional behavior is affected by this change. --- .../SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx index 576df250292b..4629defdd7f8 100644 --- a/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx @@ -22,7 +22,7 @@ type SettingsSecurityApprovedAccessDomainRowDropdownMenuProps = { export const SettingsSecurityApprovedAccessDomainRowDropdownMenu = ({ approvedAccessDomain, }: SettingsSecurityApprovedAccessDomainRowDropdownMenuProps) => { - const dropdownId = `settings-account-row-${approvedAccessDomain.id}`; + const dropdownId = `settings-approved-access-domain-row-${approvedAccessDomain.id}`; const setApprovedAccessDomains = useSetRecoilState( approvedAccessDomainsState, From 18c25444659001e24d4fa58b29b71d2f425bfa8b Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Fri, 21 Feb 2025 16:23:58 +0100 Subject: [PATCH 20/22] chore: remove insecure TLS override from codegen scripts Removed process.env.NODE_TLS_REJECT_UNAUTHORIZED modification from codegen-metadata.cjs and codegen.cjs. This ensures more secure handling of TLS settings and prevents potential misuse in production environments. --- packages/twenty-front/codegen-metadata.cjs | 1 - packages/twenty-front/codegen.cjs | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/twenty-front/codegen-metadata.cjs b/packages/twenty-front/codegen-metadata.cjs index d95a37eb71b6..53429715ca0f 100644 --- a/packages/twenty-front/codegen-metadata.cjs +++ b/packages/twenty-front/codegen-metadata.cjs @@ -1,4 +1,3 @@ -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; module.exports = { schema: (process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') + diff --git a/packages/twenty-front/codegen.cjs b/packages/twenty-front/codegen.cjs index 24269a37e30f..05effffb7a22 100644 --- a/packages/twenty-front/codegen.cjs +++ b/packages/twenty-front/codegen.cjs @@ -1,4 +1,3 @@ -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; module.exports = { schema: (process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') + From e86de6472e63c02fb9aec081f957a5ac0e45df70 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Fri, 21 Feb 2025 16:37:21 +0100 Subject: [PATCH 21/22] refactor: rename getAllApprovedAccessDomains to getApprovedAccessDomains Updated query, resolver, service, and related references to standardize naming from getAllApprovedAccessDomains to getApprovedAccessDomains. This enhances consistency and aligns with naming conventions across the codebase. --- .../src/generated-metadata/graphql.ts | 2 +- .../twenty-front/src/generated/graphql.tsx | 34 +++++++++---------- .../SettingsApprovedAccessDomainsListCard.tsx | 6 ++-- .../queries/getApprovedAccessDomains.ts | 4 +-- .../approved-access-domain.resolver.ts | 4 +-- .../approved-access-domain.service.ts | 2 +- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 3447343532c5..c18fffa572b0 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1428,7 +1428,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; - getAllApprovedAccessDomains: Array; + getApprovedAccessDomains: Array; getAvailablePackages: Scalars['JSON']['output']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getIndicatorHealthStatus: AdminPanelHealthServiceData; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 058c78b08fea..02ae5f27d546 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1292,7 +1292,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; - getAllApprovedAccessDomains: Array; + getApprovedAccessDomains: Array; getAvailablePackages: Scalars['JSON']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getIndicatorHealthStatus: AdminPanelHealthServiceData; @@ -2426,10 +2426,10 @@ export type ValidateApprovedAccessDomainMutationVariables = Exact<{ export type ValidateApprovedAccessDomainMutation = { __typename?: 'Mutation', validateApprovedAccessDomain: { __typename?: 'ApprovedAccessDomain', id: any, isValidated: boolean, domain: string, createdAt: string } }; -export type GetAllApprovedAccessDomainsQueryVariables = Exact<{ [key: string]: never; }>; +export type GetApprovedAccessDomainsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetAllApprovedAccessDomainsQuery = { __typename?: 'Query', getAllApprovedAccessDomains: Array<{ __typename?: 'ApprovedAccessDomain', id: any, createdAt: string, domain: string, isValidated: boolean }> }; +export type GetApprovedAccessDomainsQuery = { __typename?: 'Query', getApprovedAccessDomains: Array<{ __typename?: 'ApprovedAccessDomain', id: any, createdAt: string, domain: string, isValidated: boolean }> }; export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never; }>; @@ -4566,9 +4566,9 @@ export function useValidateApprovedAccessDomainMutation(baseOptions?: Apollo.Mut export type ValidateApprovedAccessDomainMutationHookResult = ReturnType; export type ValidateApprovedAccessDomainMutationResult = Apollo.MutationResult; export type ValidateApprovedAccessDomainMutationOptions = Apollo.BaseMutationOptions; -export const GetAllApprovedAccessDomainsDocument = gql` - query GetAllApprovedAccessDomains { - getAllApprovedAccessDomains { +export const GetApprovedAccessDomainsDocument = gql` + query GetApprovedAccessDomains { + getApprovedAccessDomains { id createdAt domain @@ -4578,31 +4578,31 @@ export const GetAllApprovedAccessDomainsDocument = gql` `; /** - * __useGetAllApprovedAccessDomainsQuery__ + * __useGetApprovedAccessDomainsQuery__ * - * To run a query within a React component, call `useGetAllApprovedAccessDomainsQuery` and pass it any options that fit your needs. - * When your component renders, `useGetAllApprovedAccessDomainsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * 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 } = useGetAllApprovedAccessDomainsQuery({ + * const { data, loading, error } = useGetApprovedAccessDomainsQuery({ * variables: { * }, * }); */ -export function useGetAllApprovedAccessDomainsQuery(baseOptions?: Apollo.QueryHookOptions) { +export function useGetApprovedAccessDomainsQuery(baseOptions?: Apollo.QueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(GetAllApprovedAccessDomainsDocument, options); + return Apollo.useQuery(GetApprovedAccessDomainsDocument, options); } -export function useGetAllApprovedAccessDomainsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { +export function useGetApprovedAccessDomainsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(GetAllApprovedAccessDomainsDocument, options); + return Apollo.useLazyQuery(GetApprovedAccessDomainsDocument, options); } -export type GetAllApprovedAccessDomainsQueryHookResult = ReturnType; -export type GetAllApprovedAccessDomainsLazyQueryHookResult = ReturnType; -export type GetAllApprovedAccessDomainsQueryResult = Apollo.QueryResult; +export type GetApprovedAccessDomainsQueryHookResult = ReturnType; +export type GetApprovedAccessDomainsLazyQueryHookResult = ReturnType; +export type GetApprovedAccessDomainsQueryResult = Apollo.QueryResult; export const GetSsoIdentityProvidersDocument = gql` query GetSSOIdentityProviders { getSSOIdentityProviders { diff --git a/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard.tsx b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard.tsx index 1c81c975bc78..c2de573b22fe 100644 --- a/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard.tsx @@ -14,7 +14,7 @@ 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 { useGetAllApprovedAccessDomainsQuery } from '~/generated/graphql'; +import { useGetApprovedAccessDomainsQuery } from '~/generated/graphql'; const StyledLink = styled(Link)` text-decoration: none; @@ -29,10 +29,10 @@ export const SettingsApprovedAccessDomainsListCard = () => { approvedAccessDomainsState, ); - const { loading } = useGetAllApprovedAccessDomainsQuery({ + const { loading } = useGetApprovedAccessDomainsQuery({ fetchPolicy: 'network-only', onCompleted: (data) => { - setApprovedAccessDomains(data?.getAllApprovedAccessDomains ?? []); + setApprovedAccessDomains(data?.getApprovedAccessDomains ?? []); }, onError: (error: Error) => { enqueueSnackBar(error.message, { diff --git a/packages/twenty-front/src/modules/settings/security/graphql/queries/getApprovedAccessDomains.ts b/packages/twenty-front/src/modules/settings/security/graphql/queries/getApprovedAccessDomains.ts index 7e4967f7928e..056ca9630c85 100644 --- a/packages/twenty-front/src/modules/settings/security/graphql/queries/getApprovedAccessDomains.ts +++ b/packages/twenty-front/src/modules/settings/security/graphql/queries/getApprovedAccessDomains.ts @@ -1,8 +1,8 @@ import { gql } from '@apollo/client'; export const GET_ALL_APPROVED_ACCESS_DOMAINS = gql` - query GetAllApprovedAccessDomains { - getAllApprovedAccessDomains { + query GetApprovedAccessDomains { + getApprovedAccessDomains { id createdAt domain diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts index 1c9b3a3f4c4d..17bad8786e1a 100644 --- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts @@ -61,10 +61,10 @@ export class ApprovedAccessDomainResolver { } @Query(() => [ApprovedAccessDomain]) - async getAllApprovedAccessDomains( + async getApprovedAccessDomains( @AuthWorkspace() currentWorkspace: Workspace, ): Promise> { - return await this.approvedAccessDomainService.getAllApprovedAccessDomains( + return await this.approvedAccessDomainService.getApprovedAccessDomains( currentWorkspace, ); } diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts index 767259b5c8f8..6fb6de59e793 100644 --- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts @@ -174,7 +174,7 @@ export class ApprovedAccessDomainService { await this.approvedAccessDomainRepository.delete(approvedAccessDomain); } - async getAllApprovedAccessDomains(workspace: Workspace) { + async getApprovedAccessDomains(workspace: Workspace) { return await this.approvedAccessDomainRepository.find({ where: { workspaceId: workspace.id, From 6fbb8b65a79a3446d77d6090730ff86cfea32faf Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Fri, 21 Feb 2025 16:45:07 +0100 Subject: [PATCH 22/22] feat(settings-security): add feature flag for trusted domains (#10338) Introduce a new feature flag, IsTrustedDomainsEnabled, to control the visibility of the trusted domains section in security settings. Updated back-end seeds, enums, and front-end logic to support this change. --- .../src/generated-metadata/graphql.ts | 1 + .../twenty-front/src/generated/graphql.tsx | 1 + ...tyApprovedAccessDomainValidationEffect.tsx | 2 + .../settings/security/SettingsSecurity.tsx | 22 +- .../typeorm-seeds/core/feature-flags.ts | 5 + .../engine/core-modules/auth/auth.module.ts | 4 +- ...ial-sso.service.ts => auth-sso.service.ts} | 15 +- .../{social-sso.spec.ts => auth-sso.spec.ts} | 37 ++-- .../auth/services/auth.service.spec.ts | 197 ++++++++++-------- .../auth/services/auth.service.ts | 27 ++- .../enums/feature-flag-key.enum.ts | 1 + 11 files changed, 197 insertions(+), 115 deletions(-) rename packages/twenty-server/src/engine/core-modules/auth/services/{social-sso.service.ts => auth-sso.service.ts} (85%) rename packages/twenty-server/src/engine/core-modules/auth/services/{social-sso.spec.ts => auth-sso.spec.ts} (75%) diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index c18fffa572b0..cce851177752 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -567,6 +567,7 @@ export enum FeatureFlagKey { IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled', IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled', IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled', + IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled', IsBillingPlansEnabled = 'IsBillingPlansEnabled', IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled', IsCopilotEnabled = 'IsCopilotEnabled', diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 02ae5f27d546..1a745e664d44 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -499,6 +499,7 @@ export enum FeatureFlagKey { IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled', IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled', IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled', + IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled', IsBillingPlansEnabled = 'IsBillingPlansEnabled', IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled', IsCopilotEnabled = 'IsCopilotEnabled', diff --git a/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx index cbce19f83674..a37761aca7ab 100644 --- a/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx @@ -24,11 +24,13 @@ export const SettingsSecurityApprovedAccessDomainValidationEffect = () => { }, 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, }); }, diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx index 9787a41329d6..8a2becdc39ba 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -10,6 +10,8 @@ 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%; @@ -29,6 +31,10 @@ const StyledSection = styled(Section)` export const SettingsSecurity = () => { const { t } = useLingui(); + const IsApprovedAccessDomainsEnabled = useIsFeatureEnabled( + FeatureFlagKey.IsApprovedAccessDomainsEnabled, + ); + return ( { /> - - - - + {IsApprovedAccessDomainsEnabled && ( + + + + + )}
, @@ -55,14 +55,21 @@ export class SocialSsoService { }, }, }, - relations: ['workspaceUsers', 'workspaceUsers.user'], + relations: [ + 'workspaceUsers', + 'workspaceUsers.user', + 'approvedAccessDomains', + ], }); return workspace ?? undefined; } - return await this.workspaceRepository.findOneBy({ - id: workspaceId, + return await this.workspaceRepository.findOne({ + where: { + id: workspaceId, + }, + relations: ['approvedAccessDomains'], }); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/social-sso.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts similarity index 75% rename from packages/twenty-server/src/engine/core-modules/auth/services/social-sso.spec.ts rename to packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts index b03074726494..7f2ff2ecaf8b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/social-sso.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts @@ -3,22 +3,24 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; +import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -describe('SocialSsoService', () => { - let socialSsoService: SocialSsoService; +describe('AuthSsoService', () => { + let authSsoService: AuthSsoService; let workspaceRepository: Repository; 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); + authSsoService = module.get(AuthSsoService); workspaceRepository = module.get>( getRepositoryToken(Workspace, 'core'), ); @@ -42,18 +44,21 @@ describe('SocialSsoService', () => { const mockWorkspace = { id: workspaceId } as Workspace; jest - .spyOn(workspaceRepository, 'findOneBy') + .spyOn(workspaceRepository, 'findOne') .mockResolvedValue(mockWorkspace); const result = - await socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( + await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( { authProvider: 'google', email: 'test@example.com' }, workspaceId, ); expect(result).toEqual(mockWorkspace); - expect(workspaceRepository.findOneBy).toHaveBeenCalledWith({ - id: workspaceId, + expect(workspaceRepository.findOne).toHaveBeenCalledWith({ + where: { + id: workspaceId, + }, + relations: ['approvedAccessDomains'], }); }); @@ -68,7 +73,7 @@ describe('SocialSsoService', () => { .mockResolvedValue(mockWorkspace); const result = - await socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ + await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ authProvider, email, }); @@ -83,7 +88,11 @@ describe('SocialSsoService', () => { }, }, }, - relations: ['workspaceUsers', 'workspaceUsers.user'], + relations: [ + 'workspaceUsers', + 'workspaceUsers.user', + 'approvedAccessDomains', + ], }); }); @@ -92,7 +101,7 @@ describe('SocialSsoService', () => { jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null); const result = - await socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ + await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ authProvider: 'google', email: 'notfound@example.com', }); @@ -104,7 +113,7 @@ describe('SocialSsoService', () => { jest.spyOn(environmentService, 'get').mockReturnValue(true); await expect( - socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ + authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ authProvider: 'invalid-provider' as any, email: 'test@example.com', }), diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts index 6812aa94ace1..563b0ec3d27d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts @@ -10,7 +10,7 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; +import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { ExistingUserOrNewUser } from 'src/engine/core-modules/auth/types/signInUp.type'; @@ -28,7 +28,7 @@ import { AuthService } from './auth.service'; jest.mock('bcrypt'); const UserFindOneMock = jest.fn(); -const UserWorkspaceFindOneByMock = jest.fn(); +const UserWorkspacefindOneMock = jest.fn(); const userWorkspaceServiceCheckUserWorkspaceExistsMock = jest.fn(); const workspaceInvitationGetOneWorkspaceInvitationMock = jest.fn(); @@ -41,7 +41,7 @@ describe('AuthService', () => { let service: AuthService; let userService: UserService; let workspaceRepository: Repository; - 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); userService = module.get(UserService); - socialSsoService = module.get(SocialSsoService); + authSsoService = module.get(AuthSsoService); workspaceRepository = module.get>( getRepositoryToken(Workspace, 'core'), ); @@ -160,7 +160,7 @@ describe('AuthService', () => { captchaToken: user.captchaToken, }); - UserWorkspaceFindOneByMock.mockReturnValueOnce({}); + UserWorkspacefindOneMock.mockReturnValueOnce({}); userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce({}); @@ -245,7 +245,8 @@ describe('AuthService', () => { workspace: { id: 'workspace-id', isPublicInviteLinkEnabled: true, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }); expect(spy).toHaveBeenCalledTimes(1); @@ -269,7 +270,8 @@ describe('AuthService', () => { workspace: { id: 'workspace-id', isPublicInviteLinkEnabled: true, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }), ).rejects.toThrow(new Error('Access denied')); @@ -292,7 +294,8 @@ describe('AuthService', () => { workspace: { id: 'workspace-id', isPublicInviteLinkEnabled: false, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }), ).rejects.toThrow( new AuthException( @@ -356,7 +359,7 @@ describe('AuthService', () => { } as ExistingUserOrNewUser['userData'], invitation: {} as AppToken, workspaceInviteHash: undefined, - workspace: {} as Workspace, + workspace: { approvedAccessDomains: [] } as unknown as Workspace, }); expect(spy).toHaveBeenCalledTimes(0); @@ -376,99 +379,127 @@ describe('AuthService', () => { workspaceInviteHash: 'workspaceInviteHash', workspace: { isPublicInviteLinkEnabled: true, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }); expect(spy).toHaveBeenCalledTimes(0); }); + + it('checkAccessForSignIn - allow signup for new user who target a workspace with valid trusted domain', async () => { + expect(async () => { + await service.checkAccessForSignIn({ + userData: { + type: 'newUser', + newUserPayload: { + email: 'email@domain.com', + }, + } as ExistingUserOrNewUser['userData'], + invitation: undefined, + workspaceInviteHash: 'workspaceInviteHash', + workspace: { + isPublicInviteLinkEnabled: true, + approvedAccessDomains: [ + { domain: 'domain.com', isValidated: true }, + ], + } as unknown as Workspace, + }); + }).not.toThrow(); + }); }); - it('findWorkspaceForSignInUp - signup password auth', async () => { - const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOneBy'); - const spySocialSsoService = jest.spyOn( - socialSsoService, - 'findWorkspaceFromWorkspaceIdOrAuthProvider', - ); + describe('findWorkspaceForSignInUp', () => { + it('findWorkspaceForSignInUp - signup password auth', async () => { + const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOne'); + const spyAuthSsoService = jest.spyOn( + authSsoService, + 'findWorkspaceFromWorkspaceIdOrAuthProvider', + ); + + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'password', + workspaceId: 'workspaceId', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'password', - workspaceId: 'workspaceId', + expect(result).toBeUndefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); + expect(spyAuthSsoService).toHaveBeenCalledTimes(0); }); + it('findWorkspaceForSignInUp - signup password auth with workspaceInviteHash', async () => { + const spyWorkspaceRepository = jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue({ + approvedAccessDomains: [], + } as unknown as Workspace); + const spyAuthSsoService = jest.spyOn( + authSsoService, + 'findWorkspaceFromWorkspaceIdOrAuthProvider', + ); - expect(result).toBeUndefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); - expect(spySocialSsoService).toHaveBeenCalledTimes(0); - }); - it('findWorkspaceForSignInUp - signup password auth with workspaceInviteHash', async () => { - const spyWorkspaceRepository = jest - .spyOn(workspaceRepository, 'findOneBy') - .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest.spyOn( - socialSsoService, - 'findWorkspaceFromWorkspaceIdOrAuthProvider', - ); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'password', + workspaceId: 'workspaceId', + workspaceInviteHash: 'workspaceInviteHash', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'password', - workspaceId: 'workspaceId', - workspaceInviteHash: 'workspaceInviteHash', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); + expect(spyAuthSsoService).toHaveBeenCalledTimes(0); }); + it('findWorkspaceForSignInUp - signup social sso auth with workspaceInviteHash', async () => { + const spyWorkspaceRepository = jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue({ + approvedAccessDomains: [], + } as unknown as Workspace); + const spyAuthSsoService = jest.spyOn( + authSsoService, + 'findWorkspaceFromWorkspaceIdOrAuthProvider', + ); - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); - expect(spySocialSsoService).toHaveBeenCalledTimes(0); - }); - it('findWorkspaceForSignInUp - signup social sso auth with workspaceInviteHash', async () => { - const spyWorkspaceRepository = jest - .spyOn(workspaceRepository, 'findOneBy') - .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest.spyOn( - socialSsoService, - 'findWorkspaceFromWorkspaceIdOrAuthProvider', - ); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'password', + workspaceId: 'workspaceId', + workspaceInviteHash: 'workspaceInviteHash', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'password', - workspaceId: 'workspaceId', - workspaceInviteHash: 'workspaceInviteHash', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); + expect(spyAuthSsoService).toHaveBeenCalledTimes(0); }); + it('findWorkspaceForSignInUp - signup social sso auth', async () => { + const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOne'); - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); - expect(spySocialSsoService).toHaveBeenCalledTimes(0); - }); - it('findWorkspaceForSignInUp - signup social sso auth', async () => { - const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOneBy'); + const spyAuthSsoService = jest + .spyOn(authSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') + .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest - .spyOn(socialSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') - .mockResolvedValue({} as Workspace); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'google', + workspaceId: 'workspaceId', + email: 'email', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'google', - workspaceId: 'workspaceId', - email: 'email', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); + expect(spyAuthSsoService).toHaveBeenCalledTimes(1); }); + it('findWorkspaceForSignInUp - sso auth', async () => { + const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOne'); - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); - expect(spySocialSsoService).toHaveBeenCalledTimes(1); - }); - it('findWorkspaceForSignInUp - sso auth', async () => { - const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOneBy'); + const spyAuthSsoService = jest + .spyOn(authSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') + .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest - .spyOn(socialSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') - .mockResolvedValue({} as Workspace); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'sso', + workspaceId: 'workspaceId', + email: 'email', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'sso', - workspaceId: 'workspaceId', - email: 'email', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); + expect(spyAuthSsoService).toHaveBeenCalledTimes(1); }); - - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); - expect(spySocialSsoService).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 202b44ed94c8..6268ca0c2b4b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -36,7 +36,7 @@ import { } from 'src/engine/core-modules/auth/dto/user-exists.entity'; import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; +import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { @@ -67,7 +67,7 @@ export class AuthService { private readonly refreshTokenService: RefreshTokenService, private readonly userWorkspaceService: UserWorkspaceService, private readonly workspaceInvitationService: WorkspaceInvitationService, - private readonly socialSsoService: SocialSsoService, + private readonly authSsoService: AuthSsoService, private readonly userService: UserService, private readonly signInUpService: SignInUpService, @InjectRepository(Workspace, 'core') @@ -518,15 +518,18 @@ export class AuthService { ) { if (params.workspaceInviteHash) { return ( - (await this.workspaceRepository.findOneBy({ - inviteHash: params.workspaceInviteHash, + (await this.workspaceRepository.findOne({ + where: { + inviteHash: params.workspaceInviteHash, + }, + relations: ['approvedAccessDomains'], })) ?? undefined ); } if (params.authProvider !== 'password') { return ( - (await this.socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( + (await this.authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( { email: params.email, authProvider: params.authProvider, @@ -568,6 +571,20 @@ export class AuthService { const isTargetAnExistingWorkspace = !!workspace; const isAnExistingUser = userData.type === 'existingUser'; + const email = + userData.type === 'newUser' + ? userData.newUserPayload.email + : userData.existingUser.email; + + if ( + workspace?.approvedAccessDomains.some( + (trustDomain) => + trustDomain.isValidated && trustDomain.domain === email.split('@')[1], + ) + ) { + return; + } + if ( hasPublicInviteLink && !hasPersonalInvitation && diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 105d4da170fd..82e2a7e3e004 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -11,6 +11,7 @@ export enum FeatureFlagKey { IsCommandMenuV2Enabled = 'IS_COMMAND_MENU_V2_ENABLED', IsJsonFilterEnabled = 'IS_JSON_FILTER_ENABLED', IsCustomDomainEnabled = 'IS_CUSTOM_DOMAIN_ENABLED', + IsApprovedAccessDomainsEnabled = 'IS_APPROVED_ACCESS_DOMAINS_ENABLED', IsBillingPlansEnabled = 'IS_BILLING_PLANS_ENABLED', IsRichTextV2Enabled = 'IS_RICH_TEXT_V2_ENABLED', IsNewRelationEnabled = 'IS_NEW_RELATION_ENABLED',