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:
@@ -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 />}
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ export const SettingPublicDomain = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const validationSchema = getDomainValidationSchema(t);
|
||||
const validationSchema = getDomainValidationSchema();
|
||||
|
||||
const onCreate = async () => {
|
||||
if (!isDefined(newPublicDomain)) {
|
||||
|
||||
+92
-69
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+69
-30
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+25
-14
@@ -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,
|
||||
};
|
||||
};
|
||||
+2
-4
@@ -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(
|
||||
|
||||
+10
-5
@@ -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`,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user