fix: prevent blank subdomain from being saved (#18812)

## Summary

Fixes #17941 — Saving a blank subdomain causes a redirect to
`.website.com`, effectively breaking the workspace.

**Root cause:** Three layers all fail to reject an empty string `""`:

1. **Frontend (`SettingsDomain.tsx`):** `SaveButton` has both
`onClick={onSave}` and `type="submit"`. The `onClick` fires first,
calling `handleSave()` directly without running Zod validation. So
`isDefined("")` returns `true`, the confirmation modal opens, and the
blank subdomain is submitted.

2. **Backend DTO (`update-workspace-input.ts`):** The `subdomain` field
has `@IsString()` + `@IsOptional()` but no pattern validation, so an
empty string passes the DTO layer.

3. **Backend service (`workspace.service.ts:152`):** `if
(payload.subdomain && ...)` — empty string is falsy in JS, so it skips
`validateSubdomainOrThrow()` entirely and writes `subdomain: ""` to the
database.

**The crash:** After save, the redirect logic does
`"myworkspace.website.com".replace("myworkspace", "")` →
`".website.com"`, sending the user to an invalid URL.

## Fix

- **Frontend:** Call `form.trigger()` at the start of `handleSave` to
run Zod validation regardless of whether the function was invoked via
`onClick` or `form.handleSubmit`. Returns early with validation error if
invalid.
- **Backend DTO:** Add `@Matches(/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/)`
to reject invalid subdomains at the request validation layer
(defense-in-depth).
- **Backend service:** Change `if (payload.subdomain && ...)` to `if
(isDefined(payload.subdomain) && ...)` so empty strings route through
`validateSubdomainOrThrow()` instead of being silently skipped.

## Test plan

- [x] Existing `is-subdomain-valid.util.spec.ts` tests pass (36/36)
- [x] TypeScript type checks pass for both `twenty-server` and
`twenty-front`
- [x] oxlint passes on all changed files
- [x] Prettier passes on all changed files
- [ ] Manual: Navigate to Settings > Domains, clear the subdomain field,
click Save — should show validation error, not redirect

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
oniani1
2026-03-21 18:49:21 +04:00
committed by GitHub
parent fc9723949b
commit cd651f57cb
26 changed files with 803 additions and 394 deletions
@@ -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={<SettingsLogicFunctionDetail />}
/>
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
<Route path={SettingsPath.Domain} element={<SettingsDomain />} />
<Route
path={SettingsPath.Subdomain}
element={<SettingsSubdomainPage />}
/>
<Route
path={SettingsPath.CustomDomain}
element={<SettingsCustomDomainPage />}
/>
<Route
path={SettingsPath.NewEmailingDomain}
element={<SettingsNewEmailingDomain />}
@@ -99,7 +99,7 @@ export const SettingPublicDomain = () => {
});
};
const validationSchema = getDomainValidationSchema(t);
const validationSchema = getDomainValidationSchema();
const onCreate = async () => {
if (!isDefined(newPublicDomain)) {
@@ -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 (
<Section>
<H2Title
title={t`Custom Domain`}
description={t`Set the name of your custom domain and configure your DNS records.`}
/>
<CheckCustomDomainValidRecordsEffect />
<StyledDomainFormWrapper>
<Controller
name="customDomain"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<SubMenuTopBarContainer
title={t`Custom Domain`}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Domains</Trans>,
href: getSettingsPath(SettingsPath.Domains),
},
{ children: <Trans>Custom Domain</Trans> },
]}
actionButton={
<SaveAndCancelButtons
onCancel={() => navigate(SettingsPath.Domains)}
isSaveDisabled={isSaveDisabled}
isLoading={isSubmitting}
onSave={handleSave}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Custom Domain`}
description={t`Set the name of your custom domain and configure your DNS records.`}
/>
<CheckCustomDomainValidRecordsEffect />
<StyledDomainFormWrapper>
<TextInput
value={value}
value={customDomain}
type="text"
onChange={onChange}
onChange={handleChange}
placeholder="crm.yourdomain.com"
error={error?.message}
error={error}
fullWidth
/>
{currentWorkspace?.customDomain && (
<StyledButtonGroupContainer>
<ButtonGroup>
<StyledButtonContainer>
<Button
isLoading={isRecordsLoading}
Icon={IconReload}
title={t`Reload`}
variant="primary"
onClick={checkCustomDomainRecords}
type="button"
/>
</StyledButtonContainer>
<StyledButtonContainer>
<Button
Icon={IconTrash}
variant="primary"
onClick={handleDelete}
/>
</StyledButtonContainer>
</ButtonGroup>
</StyledButtonGroupContainer>
)}
</StyledDomainFormWrapper>
{currentWorkspace?.customDomain && (
<StyledRecordsWrapper>
{customDomainRecords && (
<SettingsDomainRecords records={customDomainRecords.records} />
)}
</StyledRecordsWrapper>
)}
/>
{currentWorkspace?.customDomain && (
<StyledButtonGroupContainer>
<ButtonGroup>
<StyledButtonContainer>
<Button
isLoading={isLoading}
Icon={IconReload}
title={t`Reload`}
variant="primary"
onClick={checkCustomDomainRecords}
type="button"
/>
</StyledButtonContainer>
<StyledButtonContainer>
<Button
Icon={IconTrash}
variant="primary"
onClick={deleteCustomDomain}
/>
</StyledButtonContainer>
</ButtonGroup>
</StyledButtonGroupContainer>
)}
</StyledDomainFormWrapper>
{currentWorkspace?.customDomain && (
<StyledRecordsWrapper>
{customDomainRecords && (
<SettingsDomainRecords records={customDomainRecords.records} />
)}
</StyledRecordsWrapper>
)}
</Section>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -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 (
<Section>
<H2Title
<>
<SubMenuTopBarContainer
title={t`Subdomain`}
description={t`Set the name of your subdomain`}
/>
<StyledDomainFormWrapper>
<Controller
name="subdomain"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<>
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Domains</Trans>,
href: getSettingsPath(SettingsPath.Domains),
},
{ children: <Trans>Subdomain</Trans> },
]}
actionButton={
<SaveAndCancelButtons
onCancel={() => navigate(SettingsPath.Domains)}
isSaveDisabled={isSaveDisabled}
isLoading={isSubmitting}
onSave={handleSave}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Subdomain`}
description={t`Set the name of your subdomain`}
/>
<StyledDomainFormWrapper>
<TextInput
value={value}
value={subdomain}
type="text"
onChange={onChange}
error={error?.message}
onChange={handleChange}
error={error}
disabled={!!currentWorkspace?.customDomain}
rightAdornment={
isDefined(domainConfiguration.frontDomain)
@@ -50,10 +83,16 @@ export const SettingsSubdomain = () => {
}
fullWidth
/>
</>
)}
/>
</StyledDomainFormWrapper>
</Section>
</StyledDomainFormWrapper>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
<ConfirmationModal
modalInstanceId={SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID}
title={t`Change subdomain?`}
subtitle={t`You're about to change your workspace subdomain. This action will log out all users.`}
onConfirmClick={handleConfirm}
/>
</>
);
};
@@ -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 (
<UndecoratedLink to={getSettingsPath(SettingsPath.Domain)}>
<SettingsCard
title={t`Customize Domain`}
Icon={<IconWorld />}
Status={
currentWorkspace?.customDomain &&
currentWorkspace?.isCustomDomainEnabled ? (
<Status text={t`Active`} color="turquoise" />
) : currentWorkspace?.customDomain ? (
<Status text={t`Inactive`} color="orange" />
) : undefined
}
/>
</UndecoratedLink>
<>
<UndecoratedLink to={getSettingsPath(SettingsPath.Subdomain)}>
<SettingsCard title={t`Subdomain`} Icon={<IconWorld />} />
</UndecoratedLink>
{isCloudflareIntegrationEnabled && (
<UndecoratedLink to={getSettingsPath(SettingsPath.CustomDomain)}>
<SettingsCard
title={t`Custom Domain`}
Icon={<IconWorld />}
Status={
currentWorkspace?.customDomain &&
currentWorkspace?.isCustomDomainEnabled ? (
<Status text={t`Active`} color="turquoise" />
) : currentWorkspace?.customDomain ? (
<Status text={t`Inactive`} color="orange" />
) : undefined
}
/>
</UndecoratedLink>
)}
</>
);
};
@@ -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<string | undefined>();
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,
};
};
@@ -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<string | undefined>();
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,
};
};
@@ -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<typeof useLingui>['t'],
) =>
export const getDomainValidationSchema = () =>
z
.string()
.regex(
@@ -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<typeof useLingui>['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`,
});
@@ -0,0 +1,4 @@
/* @license Enterprise */
import { SettingsCustomDomain } from '@/settings/domains/components/SettingsCustomDomain';
export const SettingsCustomDomainPage = () => <SettingsCustomDomain />;
@@ -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 (
<>
<form onSubmit={form.handleSubmit(handleSave)}>
{/* oxlint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
<SubMenuTopBarContainer
title={t`Domain`}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Domains</Trans>,
href: getSettingsPath(SettingsPath.Domains),
},
{ children: <Trans>Domain</Trans> },
]}
actionButton={
<SaveAndCancelButtons
onCancel={() => navigate(SettingsPath.Domains)}
isSaveDisabled={isSubmitting}
onSave={handleSave}
/>
}
>
<SettingsPageContainer>
<SettingsSubdomain />
{isCloudflareIntegrationEnabled && <SettingsCustomDomain />}
</SettingsPageContainer>
</SubMenuTopBarContainer>
</FormProvider>
</form>
<ConfirmationModal
modalInstanceId={SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID}
title={t`Change subdomain?`}
subtitle={t`You're about to change your workspace subdomain. This action will log out all users.`}
onConfirmClick={() => {
const values = form.getValues();
currentWorkspace &&
updateSubdomain(values.subdomain, currentWorkspace);
}}
/>
</>
);
};
@@ -0,0 +1,3 @@
import { SettingsSubdomain } from '@/settings/domains/components/SettingsSubdomain';
export const SettingsSubdomainPage = () => <SettingsSubdomain />;
@@ -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<PageDecoratorArgs> = {
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<typeof SettingsCustomDomainPage>;
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();
},
};
@@ -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<PageDecoratorArgs> = {
title: 'Pages/Settings/Domains/SettingsSubdomain',
component: SettingsSubdomainPage,
decorators: [PageDecorator],
args: { routePath: '/settings/domains/subdomain' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsSubdomainPage>;
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();
},
};
@@ -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())
);
};
@@ -1,2 +0,0 @@
export const VALID_SUBDOMAIN_PATTERN =
/^(?!api-).*^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/;
@@ -149,7 +149,10 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
workspaceActivationStatus: workspace.activationStatus,
});
if (payload.subdomain && workspace.subdomain !== payload.subdomain) {
if (
isDefined(payload.subdomain) &&
workspace.subdomain !== payload.subdomain
) {
await this.subdomainManagerService.validateSubdomainOrThrow(
payload.subdomain,
);
@@ -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', () => {
@@ -0,0 +1,3 @@
// 330 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]$/;
@@ -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';
@@ -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',
@@ -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';
@@ -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);
});
});
});
@@ -1,5 +1,6 @@
export * from './isDefined';
export * from './assertIsDefinedOrThrow';
export * from './isValidLocale';
export * from './isValidTwentySubdomain';
export * from './isValidUuid';
export * from './normalizeLocale';
@@ -0,0 +1,5 @@
import { SUBDOMAIN_PATTERN } from '@/constants/SubdomainPattern';
export const isValidTwentySubdomain = (subdomain: string): boolean => {
return SUBDOMAIN_PATTERN.test(subdomain);
};