diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 0f33609edb..0c989b0521 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -129,12 +129,20 @@ const SettingsDomains = lazy(() => })), ); -const SettingsDomain = lazy(() => - import('~/pages/settings/domains/SettingsDomain').then((module) => ({ - default: module.SettingsDomain, +const SettingsSubdomainPage = lazy(() => + import('~/pages/settings/domains/SettingsSubdomainPage').then((module) => ({ + default: module.SettingsSubdomainPage, })), ); +const SettingsCustomDomainPage = lazy(() => + import('~/pages/settings/domains/SettingsCustomDomainPage').then( + (module) => ({ + default: module.SettingsCustomDomainPage, + }), + ), +); + const SettingsApiWebhooks = lazy(() => import('~/pages/settings/workspace/SettingsApiWebhooks').then((module) => ({ default: module.SettingsApiWebhooks, @@ -497,7 +505,14 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => ( element={} /> } /> - } /> + } + /> + } + /> } diff --git a/packages/twenty-front/src/modules/settings/domains/components/SettingPublicDomain.tsx b/packages/twenty-front/src/modules/settings/domains/components/SettingPublicDomain.tsx index e77ab19260..8c8edac5cc 100644 --- a/packages/twenty-front/src/modules/settings/domains/components/SettingPublicDomain.tsx +++ b/packages/twenty-front/src/modules/settings/domains/components/SettingPublicDomain.tsx @@ -99,7 +99,7 @@ export const SettingPublicDomain = () => { }); }; - const validationSchema = getDomainValidationSchema(t); + const validationSchema = getDomainValidationSchema(); const onCreate = async () => { if (!isDefined(newPublicDomain)) { diff --git a/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomain.tsx b/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomain.tsx index e6dcf5cddf..8dc89d49a9 100644 --- a/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomain.tsx +++ b/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomain.tsx @@ -1,18 +1,24 @@ /* @license Enterprise */ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { CheckCustomDomainValidRecordsEffect } from '@/settings/domains/components/CheckCustomDomainValidRecordsEffect'; +import { SettingsDomainRecords } from '@/settings/domains/components/SettingsDomainRecords'; +import { useSettingsCustomDomain } from '@/settings/domains/hooks/useSettingsCustomDomain'; +import { customDomainRecordsState } from '@/settings/domains/states/customDomainRecordsState'; import { TextInput } from '@/ui/input/components/TextInput'; -import { styled } from '@linaria/react'; -import { useLingui } from '@lingui/react/macro'; -import { Controller, useFormContext } from 'react-hook-form'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { styled } from '@linaria/react'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath } from 'twenty-shared/utils'; import { H2Title, IconReload, IconTrash } from 'twenty-ui/display'; import { Button, ButtonGroup } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { SettingsDomainRecords } from '@/settings/domains/components/SettingsDomainRecords'; -import { CheckCustomDomainValidRecordsEffect } from '@/settings/domains/components/CheckCustomDomainValidRecordsEffect'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { useCheckCustomDomainValidRecords } from '@/settings/domains/hooks/useCheckCustomDomainValidRecords'; -import { customDomainRecordsState } from '@/settings/domains/states/customDomainRecordsState'; const StyledDomainFormWrapper = styled.div` display: flex; @@ -38,78 +44,95 @@ const StyledRecordsWrapper = styled.div` `; export const SettingsCustomDomain = () => { - const { customDomainRecords, isLoading } = useAtomStateValue( - customDomainRecordsState, - ); - + const navigate = useNavigateSettings(); + const { t } = useLingui(); + const currentWorkspace = useAtomStateValue(currentWorkspaceState); + const { customDomainRecords, isLoading: isRecordsLoading } = + useAtomStateValue(customDomainRecordsState); const { checkCustomDomainRecords } = useCheckCustomDomainValidRecords(); - const currentWorkspace = useAtomStateValue(currentWorkspaceState); - - const { t } = useLingui(); - - const { control, setValue, trigger } = useFormContext<{ - customDomain: string; - }>(); - - const deleteCustomDomain = () => { - setValue('customDomain', ''); - trigger(); - }; + const { + customDomain, + error, + isSubmitting, + isSaveDisabled, + handleChange, + handleDelete, + handleSave, + } = useSettingsCustomDomain(); return ( -
- - - - ( + Workspace, + href: getSettingsPath(SettingsPath.Workspace), + }, + { + children: Domains, + href: getSettingsPath(SettingsPath.Domains), + }, + { children: Custom Domain }, + ]} + actionButton={ + navigate(SettingsPath.Domains)} + isSaveDisabled={isSaveDisabled} + isLoading={isSubmitting} + onSave={handleSave} + /> + } + > + +
+ + + + {currentWorkspace?.customDomain && ( + + + +
+
+ + ); }; diff --git a/packages/twenty-front/src/modules/settings/domains/components/SettingsSubdomain.tsx b/packages/twenty-front/src/modules/settings/domains/components/SettingsSubdomain.tsx index 3ba3b7e798..e31cfd248e 100644 --- a/packages/twenty-front/src/modules/settings/domains/components/SettingsSubdomain.tsx +++ b/packages/twenty-front/src/modules/settings/domains/components/SettingsSubdomain.tsx @@ -1,14 +1,22 @@ -import { TextInput } from '@/ui/input/components/TextInput'; -import { styled } from '@linaria/react'; -import { useLingui } from '@lingui/react/macro'; -import { Controller, useFormContext } from 'react-hook-form'; - import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; -import { isDefined } from 'twenty-shared/utils'; +import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { + SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID, + useSettingsSubdomain, +} from '@/settings/domains/hooks/useSettingsSubdomain'; +import { TextInput } from '@/ui/input/components/TextInput'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { styled } from '@linaria/react'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; import { H2Title } from 'twenty-ui/display'; import { Section } from 'twenty-ui/layout'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; const StyledDomainFormWrapper = styled.div` align-items: center; @@ -16,32 +24,57 @@ const StyledDomainFormWrapper = styled.div` `; export const SettingsSubdomain = () => { - const domainConfiguration = useAtomStateValue(domainConfigurationState); + const navigate = useNavigateSettings(); const { t } = useLingui(); - + const domainConfiguration = useAtomStateValue(domainConfigurationState); const currentWorkspace = useAtomStateValue(currentWorkspaceState); - const { control } = useFormContext<{ - subdomain: string; - }>(); + const { + subdomain, + error, + isSubmitting, + isSaveDisabled, + handleChange, + handleSave, + handleConfirm, + } = useSettingsSubdomain(); return ( -
- + - - ( - <> + links={[ + { + children: Workspace, + href: getSettingsPath(SettingsPath.Workspace), + }, + { + children: Domains, + href: getSettingsPath(SettingsPath.Domains), + }, + { children: Subdomain }, + ]} + actionButton={ + navigate(SettingsPath.Domains)} + isSaveDisabled={isSaveDisabled} + isLoading={isSubmitting} + onSave={handleSave} + /> + } + > + +
+ + { } fullWidth /> - - )} - /> - -
+
+
+ + + + ); }; diff --git a/packages/twenty-front/src/modules/settings/domains/components/SettingsWorkspaceDomainCard.tsx b/packages/twenty-front/src/modules/settings/domains/components/SettingsWorkspaceDomainCard.tsx index 77545aeee4..c839631d92 100644 --- a/packages/twenty-front/src/modules/settings/domains/components/SettingsWorkspaceDomainCard.tsx +++ b/packages/twenty-front/src/modules/settings/domains/components/SettingsWorkspaceDomainCard.tsx @@ -1,4 +1,5 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { isCloudflareIntegrationEnabledState } from '@/client-config/states/isCloudflareIntegrationEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { SettingsCard } from '@/settings/components/SettingsCard'; import { useLingui } from '@lingui/react/macro'; @@ -13,6 +14,9 @@ export const SettingsWorkspaceDomainCard = () => { const isMultiWorkspaceEnabled = useAtomStateValue( isMultiWorkspaceEnabledState, ); + const isCloudflareIntegrationEnabled = useAtomStateValue( + isCloudflareIntegrationEnabledState, + ); const currentWorkspace = useAtomStateValue(currentWorkspaceState); if (!isMultiWorkspaceEnabled) { @@ -20,19 +24,26 @@ export const SettingsWorkspaceDomainCard = () => { } return ( - - } - Status={ - currentWorkspace?.customDomain && - currentWorkspace?.isCustomDomainEnabled ? ( - - ) : currentWorkspace?.customDomain ? ( - - ) : undefined - } - /> - + <> + + } /> + + {isCloudflareIntegrationEnabled && ( + + } + Status={ + currentWorkspace?.customDomain && + currentWorkspace?.isCustomDomainEnabled ? ( + + ) : currentWorkspace?.customDomain ? ( + + ) : undefined + } + /> + + )} + ); }; diff --git a/packages/twenty-front/src/modules/settings/domains/hooks/useSettingsCustomDomain.ts b/packages/twenty-front/src/modules/settings/domains/hooks/useSettingsCustomDomain.ts new file mode 100644 index 0000000000..4093167819 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/domains/hooks/useSettingsCustomDomain.ts @@ -0,0 +1,99 @@ +/* @license Enterprise */ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useCheckCustomDomainValidRecords } from '@/settings/domains/hooks/useCheckCustomDomainValidRecords'; +import { getDomainValidationSchema } from '@/settings/domains/utils/getDomainValidationSchema'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; +import { CombinedGraphQLErrors } from '@apollo/client/errors'; +import { useMutation } from '@apollo/client/react'; +import { useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql'; + +export const useSettingsCustomDomain = () => { + const { t } = useLingui(); + const domainSchema = getDomainValidationSchema(); + + const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); + const [updateWorkspace] = useMutation(UpdateWorkspaceDocument); + const { checkCustomDomainRecords } = useCheckCustomDomainValidRecords(); + + const [currentWorkspace, setCurrentWorkspace] = useAtomState( + currentWorkspaceState, + ); + + const [customDomain, setCustomDomain] = useState( + currentWorkspace?.customDomain ?? '', + ); + const [error, setError] = useState(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleChange = (value: string) => { + setCustomDomain(value); + + const result = domainSchema.safeParse(value); + + setError(result.success ? undefined : result.error.issues[0].message); + }; + + const handleDelete = () => { + setCustomDomain(''); + setError(undefined); + }; + + const hasChanged = customDomain !== (currentWorkspace?.customDomain ?? ''); + const isSaveDisabled = !hasChanged || isDefined(error) || isSubmitting; + + const handleSave = () => { + if (!isDefined(currentWorkspace) || isSubmitting) { + return; + } + + setIsSubmitting(true); + + const domainValue = customDomain.length > 0 ? customDomain : null; + + updateWorkspace({ + variables: { + input: { customDomain: domainValue }, + }, + onCompleted: () => { + setCurrentWorkspace({ + ...currentWorkspace, + customDomain: domainValue, + }); + enqueueSuccessSnackBar({ message: t`Custom domain updated` }); + setIsSubmitting(false); + checkCustomDomainRecords(); + }, + onError: (mutationError) => { + if ( + CombinedGraphQLErrors.is(mutationError) && + mutationError.errors[0]?.extensions?.code === 'CONFLICT' + ) { + setError(t`Domain already taken`); + setIsSubmitting(false); + + return; + } + if (CombinedGraphQLErrors.is(mutationError)) { + enqueueErrorSnackBar({ apolloError: mutationError }); + } else { + enqueueErrorSnackBar({}); + } + setIsSubmitting(false); + }, + }); + }; + + return { + customDomain, + error, + isSubmitting, + isSaveDisabled, + handleChange, + handleDelete, + handleSave, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/domains/hooks/useSettingsSubdomain.ts b/packages/twenty-front/src/modules/settings/domains/hooks/useSettingsSubdomain.ts new file mode 100644 index 0000000000..3bc1ddb131 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/domains/hooks/useSettingsSubdomain.ts @@ -0,0 +1,104 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; +import { getSubdomainValidationSchema } from '@/settings/domains/utils/getSubdomainValidationSchema'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; +import { CombinedGraphQLErrors } from '@apollo/client/errors'; +import { useMutation } from '@apollo/client/react'; +import { useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql'; + +export const SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID = + 'subdomain-change-confirmation-modal'; + +export const useSettingsSubdomain = () => { + const { t } = useLingui(); + const subdomainSchema = getSubdomainValidationSchema(); + + const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); + const [updateWorkspace] = useMutation(UpdateWorkspaceDocument); + const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); + const { openModal, closeModal } = useModal(); + + const [currentWorkspace, setCurrentWorkspace] = useAtomState( + currentWorkspaceState, + ); + + const [subdomain, setSubdomain] = useState(currentWorkspace?.subdomain ?? ''); + const [error, setError] = useState(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleChange = (value: string) => { + setSubdomain(value); + + const result = subdomainSchema.safeParse(value); + + setError(result.success ? undefined : result.error.issues[0].message); + }; + + const hasChanged = subdomain !== currentWorkspace?.subdomain; + const isSaveDisabled = !hasChanged || isDefined(error) || isSubmitting; + + const handleSave = () => { + if (isDefined(currentWorkspace)) { + openModal(SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID); + } + }; + + const handleConfirm = () => { + if (!isDefined(currentWorkspace) || isSubmitting) { + return; + } + + setIsSubmitting(true); + updateWorkspace({ + variables: { + input: { subdomain }, + }, + onError: (mutationError) => { + if ( + CombinedGraphQLErrors.is(mutationError) && + mutationError.errors[0]?.extensions?.code === 'CONFLICT' + ) { + closeModal(SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID); + setError(t`Subdomain already taken`); + setIsSubmitting(false); + + return; + } + if (CombinedGraphQLErrors.is(mutationError)) { + enqueueErrorSnackBar({ apolloError: mutationError }); + } else { + enqueueErrorSnackBar({}); + } + setIsSubmitting(false); + }, + onCompleted: async () => { + const currentUrl = new URL(window.location.href); + + currentUrl.hostname = new URL( + currentWorkspace.workspaceUrls.subdomainUrl, + ).hostname.replace(currentWorkspace.subdomain, subdomain); + + setCurrentWorkspace({ ...currentWorkspace, subdomain }); + enqueueSuccessSnackBar({ message: t`Subdomain updated` }); + setIsSubmitting(false); + + await redirectToWorkspaceDomain(currentUrl.toString()); + }, + }); + }; + + return { + subdomain, + error, + isSubmitting, + isSaveDisabled, + handleChange, + handleSave, + handleConfirm, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/domains/utils/getDomainValidationSchema.ts b/packages/twenty-front/src/modules/settings/domains/utils/getDomainValidationSchema.ts index a19000ea45..0d9cb28e0c 100644 --- a/packages/twenty-front/src/modules/settings/domains/utils/getDomainValidationSchema.ts +++ b/packages/twenty-front/src/modules/settings/domains/utils/getDomainValidationSchema.ts @@ -1,9 +1,7 @@ +import { t } from '@lingui/core/macro'; import { z } from 'zod'; -import { type useLingui } from '@lingui/react/macro'; -export const getDomainValidationSchema = ( - t: ReturnType['t'], -) => +export const getDomainValidationSchema = () => z .string() .regex( diff --git a/packages/twenty-front/src/modules/settings/domains/utils/getSubdomainValidationSchema.ts b/packages/twenty-front/src/modules/settings/domains/utils/getSubdomainValidationSchema.ts index ba8cab8ff4..bc1b5cf019 100644 --- a/packages/twenty-front/src/modules/settings/domains/utils/getSubdomainValidationSchema.ts +++ b/packages/twenty-front/src/modules/settings/domains/utils/getSubdomainValidationSchema.ts @@ -1,13 +1,18 @@ +import { t } from '@lingui/core/macro'; +import { + RESERVED_SUBDOMAINS, + SUBDOMAIN_PATTERN, +} from 'twenty-shared/constants'; import { z } from 'zod'; -import { type useLingui } from '@lingui/react/macro'; -export const getSubdomainValidationSchema = ( - t: ReturnType['t'], -) => +export const getSubdomainValidationSchema = () => z .string() .min(3, { message: t`Subdomain can not be shorter than 3 characters` }) .max(30, { message: t`Subdomain can not be longer than 30 characters` }) - .regex(/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/, { + .regex(SUBDOMAIN_PATTERN, { message: t`Use letter, number and dash only. Start and finish with a letter or a number`, + }) + .refine((value) => !RESERVED_SUBDOMAINS.includes(value.toLowerCase()), { + message: t`This subdomain is reserved`, }); diff --git a/packages/twenty-front/src/pages/settings/domains/SettingsCustomDomainPage.tsx b/packages/twenty-front/src/pages/settings/domains/SettingsCustomDomainPage.tsx new file mode 100644 index 0000000000..2b22566478 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/domains/SettingsCustomDomainPage.tsx @@ -0,0 +1,4 @@ +/* @license Enterprise */ +import { SettingsCustomDomain } from '@/settings/domains/components/SettingsCustomDomain'; + +export const SettingsCustomDomainPage = () => ; diff --git a/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx deleted file mode 100644 index 9ea54209ab..0000000000 --- a/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import { - type CurrentWorkspace, - currentWorkspaceState, -} from '@/auth/states/currentWorkspaceState'; -import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; -import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; -import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; -import { useModal } from '@/ui/layout/modal/hooks/useModal'; -import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; -import { CombinedGraphQLErrors } from '@apollo/client/errors'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans, useLingui } from '@lingui/react/macro'; -import { FormProvider, useForm } from 'react-hook-form'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; -import { SettingsPath } from 'twenty-shared/types'; -import { getSettingsPath, isDefined } from 'twenty-shared/utils'; -import { z } from 'zod'; -import { useMutation } from '@apollo/client/react'; -import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql'; -import { useNavigateSettings } from '~/hooks/useNavigateSettings'; -import { SettingsCustomDomain } from '@/settings/domains/components/SettingsCustomDomain'; -import { SettingsSubdomain } from '@/settings/domains/components/SettingsSubdomain'; -import { useState } from 'react'; -import { getSubdomainValidationSchema } from '@/settings/domains/utils/getSubdomainValidationSchema'; -import { getDomainValidationSchema } from '@/settings/domains/utils/getDomainValidationSchema'; -import { useCheckCustomDomainValidRecords } from '@/settings/domains/hooks/useCheckCustomDomainValidRecords'; -import { isCloudflareIntegrationEnabledState } from '@/client-config/states/isCloudflareIntegrationEnabledState'; - -export const SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID = - 'subdomain-change-confirmation-modal'; - -export const SettingsDomain = () => { - const navigate = useNavigateSettings(); - const { checkCustomDomainRecords } = useCheckCustomDomainValidRecords(); - const { t } = useLingui(); - const isCloudflareIntegrationEnabled = useAtomStateValue( - isCloudflareIntegrationEnabledState, - ); - - const validationSchema = z - .object({ - subdomain: getSubdomainValidationSchema(t), - customDomain: getDomainValidationSchema(t), - }) - .required(); - - const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); - const [updateWorkspace] = useMutation(UpdateWorkspaceDocument); - const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); - const [isSubmitting, setIsSubmitting] = useState(false); - - const [currentWorkspace, setCurrentWorkspace] = useAtomState( - currentWorkspaceState, - ); - - const { openModal, closeModal } = useModal(); - - const form = useForm<{ - subdomain: string; - customDomain: string | null; - }>({ - mode: 'onSubmit', - delayError: 500, - defaultValues: { - subdomain: currentWorkspace?.subdomain ?? '', - customDomain: currentWorkspace?.customDomain ?? '', - }, - resolver: zodResolver(validationSchema), - }); - - const subdomainValue = form.watch('subdomain'); - const customDomainValue = form.watch('customDomain'); - - const updateCustomDomain = ( - customDomain: string | null, - currentWorkspace: CurrentWorkspace, - ) => { - if (isSubmitting) { - return; - } - setIsSubmitting(true); - updateWorkspace({ - variables: { - input: { - customDomain: - isDefined(customDomain) && customDomain.length > 0 - ? customDomain - : null, - }, - }, - onCompleted: () => { - setCurrentWorkspace({ - ...currentWorkspace, - customDomain: - customDomain && customDomain.length > 0 ? customDomain : null, - }); - enqueueSuccessSnackBar({ - message: t`Custom domain updated`, - }); - setIsSubmitting(false); - checkCustomDomainRecords(); - }, - onError: (error) => { - if ( - CombinedGraphQLErrors.is(error) && - error.errors[0]?.extensions?.code === 'CONFLICT' - ) { - return form.control.setError('subdomain', { - type: 'manual', - message: t`Subdomain already taken`, - }); - } - if (CombinedGraphQLErrors.is(error)) { - enqueueErrorSnackBar({ - apolloError: error, - }); - } else { - enqueueErrorSnackBar({}); - } - setIsSubmitting(false); - }, - }); - }; - - const updateSubdomain = ( - subdomain: string, - currentWorkspace: CurrentWorkspace, - ) => { - if (isSubmitting) { - return; - } - setIsSubmitting(true); - updateWorkspace({ - variables: { - input: { - subdomain, - }, - }, - onError: (error) => { - if ( - CombinedGraphQLErrors.is(error) && - error.errors[0]?.extensions?.code === 'CONFLICT' - ) { - closeModal(SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID); - return form.control.setError('subdomain', { - type: 'manual', - message: t`Subdomain already taken`, - }); - } - if (CombinedGraphQLErrors.is(error)) { - enqueueErrorSnackBar({ - apolloError: error, - }); - } else { - enqueueErrorSnackBar({}); - } - setIsSubmitting(false); - }, - onCompleted: async () => { - const currentUrl = new URL(window.location.href); - - currentUrl.hostname = new URL( - currentWorkspace.workspaceUrls.subdomainUrl, - ).hostname.replace(currentWorkspace.subdomain, subdomain); - - setCurrentWorkspace({ - ...currentWorkspace, - subdomain, - }); - - enqueueSuccessSnackBar({ - message: t`Subdomain updated`, - }); - setIsSubmitting(false); - - await redirectToWorkspaceDomain(currentUrl.toString()); - }, - }); - }; - - const handleSave = async () => { - const values = form.getValues(); - - if ( - subdomainValue === currentWorkspace?.subdomain && - customDomainValue === currentWorkspace?.customDomain - ) { - return enqueueErrorSnackBar({ - message: t`No change detected`, - }); - } - - if (!isDefined(values) || !isDefined(currentWorkspace)) { - return enqueueErrorSnackBar({ - message: t`Invalid form values`, - }); - } - - if ( - isDefined(values.subdomain) && - values.subdomain !== currentWorkspace.subdomain - ) { - openModal(SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID); - return; - } - - if (values.customDomain !== currentWorkspace.customDomain) { - return updateCustomDomain(values.customDomain, currentWorkspace); - } - }; - - return ( - <> -
- {/* oxlint-disable-next-line react/jsx-props-no-spreading */} - - Workspace, - href: getSettingsPath(SettingsPath.Workspace), - }, - { - children: Domains, - href: getSettingsPath(SettingsPath.Domains), - }, - { children: Domain }, - ]} - actionButton={ - navigate(SettingsPath.Domains)} - isSaveDisabled={isSubmitting} - onSave={handleSave} - /> - } - > - - - {isCloudflareIntegrationEnabled && } - - - -
- { - const values = form.getValues(); - currentWorkspace && - updateSubdomain(values.subdomain, currentWorkspace); - }} - /> - - ); -}; diff --git a/packages/twenty-front/src/pages/settings/domains/SettingsSubdomainPage.tsx b/packages/twenty-front/src/pages/settings/domains/SettingsSubdomainPage.tsx new file mode 100644 index 0000000000..e6a11f2c97 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/domains/SettingsSubdomainPage.tsx @@ -0,0 +1,3 @@ +import { SettingsSubdomain } from '@/settings/domains/components/SettingsSubdomain'; + +export const SettingsSubdomainPage = () => ; diff --git a/packages/twenty-front/src/pages/settings/domains/__stories__/SettingsCustomDomain.stories.tsx b/packages/twenty-front/src/pages/settings/domains/__stories__/SettingsCustomDomain.stories.tsx new file mode 100644 index 0000000000..8f1668c912 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/domains/__stories__/SettingsCustomDomain.stories.tsx @@ -0,0 +1,60 @@ +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from 'storybook/test'; + +import { + PageDecorator, + type PageDecoratorArgs, +} from '~/testing/decorators/PageDecorator'; +import { graphqlMocks } from '~/testing/graphqlMocks'; + +import { SettingsCustomDomainPage } from '~/pages/settings/domains/SettingsCustomDomainPage'; + +const meta: Meta = { + title: 'Pages/Settings/Domains/SettingsCustomDomain', + component: SettingsCustomDomainPage, + decorators: [PageDecorator], + args: { routePath: '/settings/domains/custom-domain' }, + parameters: { + msw: graphqlMocks, + }, +}; + +export default meta; + +export type Story = StoryObj; + +export const Default: Story = {}; + +export const InvalidDomain: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByRole('textbox', {}, { timeout: 5000 }); + + await userEvent.clear(input); + await userEvent.type(input, 'not-a-domain'); + + const errorMessage = await canvas.findByText(/Invalid domain/); + + await expect(errorMessage).toBeVisible(); + + const saveButton = canvas.getByText('Save'); + + await expect(saveButton.closest('button')).toBeDisabled(); + }, +}; + +export const ValidDomain: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByRole('textbox', {}, { timeout: 5000 }); + + await userEvent.clear(input); + await userEvent.type(input, 'crm.example.com'); + + const saveButton = canvas.getByText('Save'); + + await expect(saveButton.closest('button')).toBeEnabled(); + }, +}; diff --git a/packages/twenty-front/src/pages/settings/domains/__stories__/SettingsSubdomain.stories.tsx b/packages/twenty-front/src/pages/settings/domains/__stories__/SettingsSubdomain.stories.tsx new file mode 100644 index 0000000000..1902406597 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/domains/__stories__/SettingsSubdomain.stories.tsx @@ -0,0 +1,102 @@ +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from 'storybook/test'; + +import { + PageDecorator, + type PageDecoratorArgs, +} from '~/testing/decorators/PageDecorator'; +import { graphqlMocks } from '~/testing/graphqlMocks'; + +import { SettingsSubdomainPage } from '~/pages/settings/domains/SettingsSubdomainPage'; + +const meta: Meta = { + title: 'Pages/Settings/Domains/SettingsSubdomain', + component: SettingsSubdomainPage, + decorators: [PageDecorator], + args: { routePath: '/settings/domains/subdomain' }, + parameters: { + msw: graphqlMocks, + }, +}; + +export default meta; + +export type Story = StoryObj; + +export const Default: Story = {}; + +export const TooShortSubdomain: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByRole('textbox', {}, { timeout: 5000 }); + + await userEvent.clear(input); + await userEvent.type(input, 'ab'); + + const errorMessage = await canvas.findByText( + 'Subdomain can not be shorter than 3 characters', + ); + + await expect(errorMessage).toBeVisible(); + + const saveButton = canvas.getByText('Save'); + + await expect(saveButton.closest('button')).toBeDisabled(); + }, +}; + +export const InvalidCharacters: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByRole('textbox', {}, { timeout: 5000 }); + + await userEvent.clear(input); + await userEvent.type(input, 'api-test'); + + const errorMessage = await canvas.findByText( + 'Use letter, number and dash only. Start and finish with a letter or a number', + ); + + await expect(errorMessage).toBeVisible(); + + const saveButton = canvas.getByText('Save'); + + await expect(saveButton.closest('button')).toBeDisabled(); + }, +}; + +export const ReservedSubdomain: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByRole('textbox', {}, { timeout: 5000 }); + + await userEvent.clear(input); + await userEvent.type(input, 'api'); + + const errorMessage = await canvas.findByText('This subdomain is reserved'); + + await expect(errorMessage).toBeVisible(); + + const saveButton = canvas.getByText('Save'); + + await expect(saveButton.closest('button')).toBeDisabled(); + }, +}; + +export const ValidSubdomain: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByRole('textbox', {}, { timeout: 5000 }); + + await userEvent.clear(input); + await userEvent.type(input, 'my-workspace'); + + const saveButton = canvas.getByText('Save'); + + await expect(saveButton.closest('button')).toBeEnabled(); + }, +}; diff --git a/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/utils/is-subdomain-valid.util.ts b/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/utils/is-subdomain-valid.util.ts index 0fa30d86d7..4e0c812fc2 100644 --- a/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/utils/is-subdomain-valid.util.ts +++ b/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/utils/is-subdomain-valid.util.ts @@ -1,9 +1,9 @@ -import { RESERVED_SUBDOMAINS } from 'src/engine/core-modules/workspace/constants/reserved-subdomains.constant'; -import { VALID_SUBDOMAIN_PATTERN } from 'src/engine/core-modules/workspace/constants/valid-subdomain-pattern.constant'; +import { RESERVED_SUBDOMAINS } from 'twenty-shared/constants'; +import { isValidTwentySubdomain } from 'twenty-shared/utils'; export const isSubdomainValid = (subdomain: string) => { return ( - VALID_SUBDOMAIN_PATTERN.test(subdomain) && + isValidTwentySubdomain(subdomain) && !RESERVED_SUBDOMAINS.includes(subdomain.toLowerCase()) ); }; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/constants/valid-subdomain-pattern.constant.ts b/packages/twenty-server/src/engine/core-modules/workspace/constants/valid-subdomain-pattern.constant.ts deleted file mode 100644 index 3678e1e07b..0000000000 --- a/packages/twenty-server/src/engine/core-modules/workspace/constants/valid-subdomain-pattern.constant.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const VALID_SUBDOMAIN_PATTERN = - /^(?!api-).*^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 8f32b63a31..bbbcf80ac7 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -149,7 +149,10 @@ export class WorkspaceService extends TypeOrmQueryService { workspaceActivationStatus: workspace.activationStatus, }); - if (payload.subdomain && workspace.subdomain !== payload.subdomain) { + if ( + isDefined(payload.subdomain) && + workspace.subdomain !== payload.subdomain + ) { await this.subdomainManagerService.validateSubdomainOrThrow( payload.subdomain, ); diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts index c25b8a102b..2bfdca2c7d 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts @@ -213,6 +213,87 @@ describe('workspace permissions', () => { ); }); }); + + it('should return a validation error when subdomain is empty string', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { subdomain: "" }) { + id + subdomain + } + } + `, + }; + + const response = await client + .post('/metadata') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send(queryData); + + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.CONFLICT, + ); + expect( + response.body.errors[0].extensions.userFriendlyMessage, + ).toBe('Invalid subdomain.'); + }); + + it('should return a validation error when subdomain has invalid characters', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { subdomain: "INVALID!" }) { + id + subdomain + } + } + `, + }; + + const response = await client + .post('/metadata') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send(queryData); + + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.CONFLICT, + ); + expect( + response.body.errors[0].extensions.userFriendlyMessage, + ).toBe('Invalid subdomain.'); + }); + + it('should return a validation error when subdomain starts with api-', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { subdomain: "api-workspace" }) { + id + subdomain + } + } + `, + }; + + const response = await client + .post('/metadata') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send(queryData); + + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.CONFLICT, + ); + expect( + response.body.errors[0].extensions.userFriendlyMessage, + ).toBe('Invalid subdomain.'); + }); }); describe('custom domain update', () => { diff --git a/packages/twenty-server/src/engine/core-modules/workspace/constants/reserved-subdomains.constant.ts b/packages/twenty-shared/src/constants/ReservedSubdomains.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/workspace/constants/reserved-subdomains.constant.ts rename to packages/twenty-shared/src/constants/ReservedSubdomains.ts diff --git a/packages/twenty-shared/src/constants/SubdomainPattern.ts b/packages/twenty-shared/src/constants/SubdomainPattern.ts new file mode 100644 index 0000000000..c335c7721b --- /dev/null +++ b/packages/twenty-shared/src/constants/SubdomainPattern.ts @@ -0,0 +1,3 @@ +// 3–30 chars: lowercase alphanumeric and hyphens, must start and end +// with a letter or number, must not start with "api-" +export const SUBDOMAIN_PATTERN = /^(?!api-)[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/; diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index 409de0aad8..0597c6e3ac 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -40,7 +40,9 @@ export { QUERY_MAX_RECORDS_FROM_RELATION } from './QueryMaxRecordsFromRelation'; export { QUOTED_STRING_REGEX } from './QuotedStringRegex'; export { RATING_VALUES } from './RatingValues'; export { RELATION_NESTED_QUERY_KEYWORDS } from './RelationNestedQueriesKeyword'; +export { RESERVED_SUBDOMAINS } from './ReservedSubdomains'; export { STANDARD_OBJECT_RECORDS_UNDER_OBJECT_RECORDS_PERMISSIONS } from './StandardObjectRecordsUnderObjectRecordsPermissions'; +export { SUBDOMAIN_PATTERN } from './SubdomainPattern'; export { TWENTY_COMPANIES_BASE_URL } from './TwentyCompaniesBaseUrl'; export { TWENTY_ICONS_BASE_URL } from './TwentyIconsBaseUrl'; export { VIEW_GROUP_VISIBLE_OPTIONS_MAX } from './ViewGroupVisibleOptionsMax'; diff --git a/packages/twenty-shared/src/types/SettingsPath.ts b/packages/twenty-shared/src/types/SettingsPath.ts index f988d56b09..a08af70984 100644 --- a/packages/twenty-shared/src/types/SettingsPath.ts +++ b/packages/twenty-shared/src/types/SettingsPath.ts @@ -22,7 +22,8 @@ export enum SettingsPath { WorkspaceMemberPage = 'members/:workspaceMemberId', Workspace = 'general', Domains = 'domains', - Domain = 'domains/domain', + Subdomain = 'domains/subdomain', + CustomDomain = 'domains/custom-domain', PublicDomain = 'domains/public-domain', NewApprovedAccessDomain = 'domains/approved-access-domain/new', NewEmailingDomain = 'domains/emailing-domain/new', diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 34c2cf84d5..1bedcb0288 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -194,6 +194,7 @@ export { isDefined } from './validation/isDefined'; export { isEmptyObject } from './validation/isEmptyObject'; export { isLabelIdentifierFieldMetadataTypes } from './validation/isLabelIdentifierFieldMetadataTypes'; export { isValidLocale } from './validation/isValidLocale'; +export { isValidTwentySubdomain } from './validation/isValidTwentySubdomain'; export { isValidUuid } from './validation/isValidUuid'; export { isValidVariable } from './validation/isValidVariable'; export { normalizeLocale } from './validation/normalizeLocale'; diff --git a/packages/twenty-shared/src/utils/validation/__tests__/isValidTwentySubdomain.test.ts b/packages/twenty-shared/src/utils/validation/__tests__/isValidTwentySubdomain.test.ts new file mode 100644 index 0000000000..2feef93a26 --- /dev/null +++ b/packages/twenty-shared/src/utils/validation/__tests__/isValidTwentySubdomain.test.ts @@ -0,0 +1,111 @@ +import { isValidTwentySubdomain } from '@/utils/validation/isValidTwentySubdomain'; + +describe('isValidTwentySubdomain', () => { + describe('valid subdomains', () => { + it('should accept standard alphanumeric subdomains', () => { + expect(isValidTwentySubdomain('abc')).toBe(true); + expect(isValidTwentySubdomain('test123')).toBe(true); + expect(isValidTwentySubdomain('company1')).toBe(true); + expect(isValidTwentySubdomain('workspace2024')).toBe(true); + }); + + it('should accept subdomains with hyphens in the middle', () => { + expect(isValidTwentySubdomain('my-company')).toBe(true); + expect(isValidTwentySubdomain('test-workspace')).toBe(true); + expect(isValidTwentySubdomain('multi-word-subdomain')).toBe(true); + expect(isValidTwentySubdomain('a-b-c-d-e')).toBe(true); + }); + + it('should accept minimum length subdomains (3 characters)', () => { + expect(isValidTwentySubdomain('abc')).toBe(true); + expect(isValidTwentySubdomain('a1b')).toBe(true); + expect(isValidTwentySubdomain('a-b')).toBe(true); + }); + + it('should accept maximum length subdomains (30 characters)', () => { + const exactly30 = 'a' + 'b'.repeat(28) + 'c'; + + expect(exactly30.length).toBe(30); + expect(isValidTwentySubdomain(exactly30)).toBe(true); + }); + + it('should accept numeric-only subdomains', () => { + expect(isValidTwentySubdomain('123')).toBe(true); + expect(isValidTwentySubdomain('456789')).toBe(true); + expect(isValidTwentySubdomain('1-2-3')).toBe(true); + }); + }); + + describe('invalid subdomains', () => { + it('should reject empty strings', () => { + expect(isValidTwentySubdomain('')).toBe(false); + }); + + it('should reject subdomains shorter than 3 characters', () => { + expect(isValidTwentySubdomain('a')).toBe(false); + expect(isValidTwentySubdomain('ab')).toBe(false); + }); + + it('should reject subdomains longer than 30 characters', () => { + const tooLong = 'a'.repeat(31); + + expect(isValidTwentySubdomain(tooLong)).toBe(false); + }); + + it('should reject subdomains starting with a hyphen', () => { + expect(isValidTwentySubdomain('-test')).toBe(false); + expect(isValidTwentySubdomain('-abc')).toBe(false); + }); + + it('should reject subdomains ending with a hyphen', () => { + expect(isValidTwentySubdomain('test-')).toBe(false); + expect(isValidTwentySubdomain('abc-')).toBe(false); + }); + + it('should reject subdomains with uppercase letters', () => { + expect(isValidTwentySubdomain('Test')).toBe(false); + expect(isValidTwentySubdomain('MyCompany')).toBe(false); + expect(isValidTwentySubdomain('WORKSPACE')).toBe(false); + }); + + it('should reject subdomains with special characters', () => { + expect(isValidTwentySubdomain('test@company')).toBe(false); + expect(isValidTwentySubdomain('my_workspace')).toBe(false); + expect(isValidTwentySubdomain('test.company')).toBe(false); + expect(isValidTwentySubdomain('workspace#1')).toBe(false); + }); + + it('should reject subdomains with spaces', () => { + expect(isValidTwentySubdomain('test company')).toBe(false); + expect(isValidTwentySubdomain(' test')).toBe(false); + expect(isValidTwentySubdomain('test ')).toBe(false); + }); + + it('should reject subdomains starting with "api-"', () => { + expect(isValidTwentySubdomain('api-test')).toBe(false); + expect(isValidTwentySubdomain('api-company')).toBe(false); + expect(isValidTwentySubdomain('api-123')).toBe(false); + }); + + it('should accept subdomains containing "api" not as prefix', () => { + expect(isValidTwentySubdomain('myapi')).toBe(true); + expect(isValidTwentySubdomain('rapid')).toBe(true); + }); + + it('should reject subdomains with only hyphens', () => { + expect(isValidTwentySubdomain('---')).toBe(false); + expect(isValidTwentySubdomain('----')).toBe(false); + }); + + it('should reject whitespace-only strings', () => { + expect(isValidTwentySubdomain(' ')).toBe(false); + expect(isValidTwentySubdomain('\t')).toBe(false); + expect(isValidTwentySubdomain('\n')).toBe(false); + }); + + it('should reject unicode characters', () => { + expect(isValidTwentySubdomain('café')).toBe(false); + expect(isValidTwentySubdomain('tëst')).toBe(false); + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/validation/index.ts b/packages/twenty-shared/src/utils/validation/index.ts index 765c3ba4d5..76e7763691 100644 --- a/packages/twenty-shared/src/utils/validation/index.ts +++ b/packages/twenty-shared/src/utils/validation/index.ts @@ -1,5 +1,6 @@ export * from './isDefined'; export * from './assertIsDefinedOrThrow'; export * from './isValidLocale'; +export * from './isValidTwentySubdomain'; export * from './isValidUuid'; export * from './normalizeLocale'; diff --git a/packages/twenty-shared/src/utils/validation/isValidTwentySubdomain.ts b/packages/twenty-shared/src/utils/validation/isValidTwentySubdomain.ts new file mode 100644 index 0000000000..7bfc84021e --- /dev/null +++ b/packages/twenty-shared/src/utils/validation/isValidTwentySubdomain.ts @@ -0,0 +1,5 @@ +import { SUBDOMAIN_PATTERN } from '@/constants/SubdomainPattern'; + +export const isValidTwentySubdomain = (subdomain: string): boolean => { + return SUBDOMAIN_PATTERN.test(subdomain); +};