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 && (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {currentWorkspace?.customDomain && (
+
+ {customDomainRecords && (
+
+ )}
+
)}
- />
- {currentWorkspace?.customDomain && (
-
-
-
-
-
-
-
-
-
-
- )}
-
- {currentWorkspace?.customDomain && (
-
- {customDomainRecords && (
-
- )}
-
- )}
-
+
+
+
);
};
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 (
- <>
-
- {
- 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);
+};