@@ -125,11 +125,17 @@ const SettingsWorkspace = lazy(() =>
|
||||
);
|
||||
|
||||
const SettingsDomains = lazy(() =>
|
||||
import('~/pages/settings/domains').then((module) => ({
|
||||
import('~/pages/settings/domains/SettingsDomains').then((module) => ({
|
||||
default: module.SettingsDomains,
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsDomain = lazy(() =>
|
||||
import('~/pages/settings/domains/SettingsDomain').then((module) => ({
|
||||
default: module.SettingsDomain,
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsApiWebhooks = lazy(() =>
|
||||
import('~/pages/settings/workspace/SettingsApiWebhooks').then((module) => ({
|
||||
default: module.SettingsApiWebhooks,
|
||||
@@ -148,12 +154,6 @@ const SettingsAgentForm = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsDomain = lazy(() =>
|
||||
import('~/pages/settings/workspace/SettingsDomain').then((module) => ({
|
||||
default: module.SettingsDomain,
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsWorkspaceMembers = lazy(() =>
|
||||
import('~/pages/settings/SettingsWorkspaceMembers').then((module) => ({
|
||||
default: module.SettingsWorkspaceMembers,
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/* @license Enterprise */
|
||||
import { useEffect } from 'react';
|
||||
import { useCheckCustomDomainValidRecords } from '@/settings/domains/hooks/useCheckCustomDomainValidRecords';
|
||||
|
||||
export const CheckCustomDomainValidRecordsEffect = () => {
|
||||
const { checkCustomDomainRecords } = useCheckCustomDomainValidRecords();
|
||||
|
||||
useEffect(() => {
|
||||
checkCustomDomainRecords();
|
||||
// Check custom domain only needs to run once at mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/* @license Enterprise */
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { H2Title, IconReload, IconTrash } from 'twenty-ui/display';
|
||||
import { Button, ButtonGroup } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { SettingsCustomDomainRecords } from '@/settings/domains/components/SettingsCustomDomainRecords';
|
||||
import { CheckCustomDomainValidRecordsEffect } from '@/settings/domains/CheckCustomDomainValidRecordsEffect';
|
||||
import { useCheckCustomDomainValidRecords } from '@/settings/domains/hooks/useCheckCustomDomainValidRecords';
|
||||
import { customDomainRecordsState } from '@/settings/domains/states/customDomainRecordsState';
|
||||
|
||||
const StyledDomainFormWrapper = styled.div`
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledButtonGroup = styled(ButtonGroup)`
|
||||
& > :not(:first-of-type) > button {
|
||||
border-left: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
align-self: flex-start;
|
||||
`;
|
||||
|
||||
const StyledRecordsWrapper = styled.div`
|
||||
margin-top: ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
& > :not(:first-of-type) {
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
}
|
||||
`;
|
||||
|
||||
export const SettingsCustomDomain = () => {
|
||||
const { customDomainRecords, isLoading } = useRecoilValue(
|
||||
customDomainRecordsState,
|
||||
);
|
||||
|
||||
const { checkCustomDomainRecords } = useCheckCustomDomainValidRecords();
|
||||
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const { control, setValue, trigger } = useFormContext<{
|
||||
customDomain: string;
|
||||
}>();
|
||||
|
||||
const deleteCustomDomain = () => {
|
||||
setValue('customDomain', '');
|
||||
trigger();
|
||||
};
|
||||
|
||||
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 } }) => (
|
||||
<TextInput
|
||||
value={value}
|
||||
type="text"
|
||||
onChange={onChange}
|
||||
placeholder="crm.yourdomain.com"
|
||||
error={error?.message}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{currentWorkspace?.customDomain && (
|
||||
<StyledButtonGroup>
|
||||
<StyledButton
|
||||
isLoading={isLoading}
|
||||
Icon={IconReload}
|
||||
title={t`Reload`}
|
||||
variant="primary"
|
||||
onClick={checkCustomDomainRecords}
|
||||
type="button"
|
||||
/>
|
||||
<StyledButton
|
||||
Icon={IconTrash}
|
||||
variant="primary"
|
||||
onClick={deleteCustomDomain}
|
||||
/>
|
||||
</StyledButtonGroup>
|
||||
)}
|
||||
</StyledDomainFormWrapper>
|
||||
{currentWorkspace?.customDomain && (
|
||||
<StyledRecordsWrapper>
|
||||
{customDomainRecords && (
|
||||
<SettingsCustomDomainRecords
|
||||
records={customDomainRecords.records}
|
||||
/>
|
||||
)}
|
||||
</StyledRecordsWrapper>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableBody } from '@/ui/layout/table/components/TableBody';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import styled from '@emotion/styled';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import {
|
||||
type CustomDomainRecord,
|
||||
type CustomDomainValidRecords,
|
||||
} from '~/generated/graphql';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
import { capitalize } from 'twenty-shared/utils';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { type ThemeColor } from 'twenty-ui/theme';
|
||||
import { Status } from 'twenty-ui/display';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { customDomainRecordsState } from '@/settings/domains/states/customDomainRecordsState';
|
||||
|
||||
const StyledTable = styled(Table)`
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
`;
|
||||
|
||||
const StyledTableCell = styled(TableCell)`
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
padding: 0 ${({ theme }) => theme.spacing(3)} 0 0;
|
||||
|
||||
&:first-of-type {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
padding-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
height: ${({ theme }) => theme.spacing(6)};
|
||||
overflow: hidden;
|
||||
user-select: text;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SettingsCustomDomainRecords = ({
|
||||
records,
|
||||
}: {
|
||||
records: CustomDomainValidRecords['records'];
|
||||
}) => {
|
||||
const { customDomainRecords } = useRecoilValue(customDomainRecordsState);
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
|
||||
const copyToClipboardDebounced = useDebouncedCallback(
|
||||
(value: string) => copyToClipboard(value),
|
||||
200,
|
||||
);
|
||||
|
||||
const rowsDefinitions = [
|
||||
{ name: 'Domain Setup', validationType: 'redirection' as const },
|
||||
{ name: 'Secure Connection', validationType: 'ssl' as const },
|
||||
];
|
||||
|
||||
const defaultValues: { status: string; color: ThemeColor } =
|
||||
currentWorkspace?.customDomain === customDomainRecords?.customDomain
|
||||
? {
|
||||
status: 'success',
|
||||
color: 'green',
|
||||
}
|
||||
: {
|
||||
status: 'loading',
|
||||
color: 'gray',
|
||||
};
|
||||
|
||||
const rows = rowsDefinitions.map<
|
||||
{ name: string; status: string; color: ThemeColor } & CustomDomainRecord
|
||||
>((row) => {
|
||||
const record = records.find(
|
||||
({ validationType }) => validationType === row.validationType,
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
throw new Error(`Record ${row.name} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: row.name,
|
||||
color:
|
||||
record && record.status === 'error'
|
||||
? 'red'
|
||||
: record && record.status === 'pending'
|
||||
? 'yellow'
|
||||
: defaultValues.color,
|
||||
...record,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledTable>
|
||||
<TableRow gridAutoColumns="30% 16% 38% 16%">
|
||||
<TableHeader>Name</TableHeader>
|
||||
<TableHeader>Type</TableHeader>
|
||||
<TableHeader>Value</TableHeader>
|
||||
<TableHeader></TableHeader>
|
||||
</TableRow>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow gridAutoColumns="30% 16% 38% 16%" key={row.name}>
|
||||
<StyledTableCell>
|
||||
<StyledButton
|
||||
title={row.key}
|
||||
onClick={() => copyToClipboardDebounced(row.key)}
|
||||
type="button"
|
||||
/>
|
||||
</StyledTableCell>
|
||||
<StyledTableCell>
|
||||
<StyledButton
|
||||
title={row.type.toUpperCase()}
|
||||
onClick={() => copyToClipboardDebounced(row.type.toUpperCase())}
|
||||
type="button"
|
||||
/>
|
||||
</StyledTableCell>
|
||||
<StyledTableCell>
|
||||
<StyledButton
|
||||
title={row.value}
|
||||
onClick={() => copyToClipboardDebounced(row.value)}
|
||||
type="button"
|
||||
/>
|
||||
</StyledTableCell>
|
||||
<StyledTableCell>
|
||||
<Status color={row.color} text={capitalize(row.status)} />
|
||||
</StyledTableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</StyledTable>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import styled from '@emotion/styled';
|
||||
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 { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
|
||||
const StyledDomainFormWrapper = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const SettingsSubdomain = () => {
|
||||
const domainConfiguration = useRecoilValue(domainConfigurationState);
|
||||
const { t } = useLingui();
|
||||
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
|
||||
const { control } = useFormContext<{
|
||||
subdomain: string;
|
||||
}>();
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Subdomain`}
|
||||
description={t`Set the name of your subdomain`}
|
||||
/>
|
||||
<StyledDomainFormWrapper>
|
||||
<Controller
|
||||
name="subdomain"
|
||||
control={control}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<>
|
||||
<TextInput
|
||||
value={value}
|
||||
type="text"
|
||||
onChange={onChange}
|
||||
error={error?.message}
|
||||
disabled={!!currentWorkspace?.customDomain}
|
||||
rightAdornment={
|
||||
isDefined(domainConfiguration.frontDomain)
|
||||
? `.${domainConfiguration.frontDomain}`
|
||||
: undefined
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</StyledDomainFormWrapper>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
|
||||
import { SettingsCard } from '@/settings/components/SettingsCard';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { IconWorld, Status } from 'twenty-ui/display';
|
||||
import { UndecoratedLink } from 'twenty-ui/navigation';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
export const SettingsWorkspaceDomainCard = () => {
|
||||
const { t } = useLingui();
|
||||
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
|
||||
if (!isMultiWorkspaceEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { useCheckCustomDomainValidRecordsMutation } from '~/generated-metadata/graphql';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { customDomainRecordsState } from '@/settings/domains/states/customDomainRecordsState';
|
||||
|
||||
export const useCheckCustomDomainValidRecords = () => {
|
||||
const [checkCustomDomainValidRecords] =
|
||||
useCheckCustomDomainValidRecordsMutation();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const setCustomDomainRecords = useSetRecoilState(customDomainRecordsState);
|
||||
|
||||
const checkCustomDomainRecords = () => {
|
||||
setCustomDomainRecords((currentState) => ({
|
||||
...currentState,
|
||||
isLoading: true,
|
||||
}));
|
||||
checkCustomDomainValidRecords({
|
||||
onCompleted: (data) => {
|
||||
setCustomDomainRecords((currentState) => ({
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
...(isDefined(data.checkCustomDomainValidRecords)
|
||||
? { customDomainRecords: data.checkCustomDomainValidRecords }
|
||||
: {}),
|
||||
}));
|
||||
},
|
||||
onError: (error) => {
|
||||
enqueueErrorSnackBar({ apolloError: error });
|
||||
setCustomDomainRecords((currentState) => ({
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
}));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
checkCustomDomainRecords,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type CustomDomainValidRecords } from '~/generated/graphql';
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
|
||||
export const customDomainRecordsState = createState<{
|
||||
customDomainRecords: CustomDomainValidRecords | null;
|
||||
isLoading: boolean;
|
||||
}>({
|
||||
key: 'customDomainRecordsState',
|
||||
defaultValue: { isLoading: false, customDomainRecords: null },
|
||||
});
|
||||
@@ -21,8 +21,9 @@ export enum SettingsPath {
|
||||
ServerlessFunctionDetail = 'functions/:serverlessFunctionId',
|
||||
WorkspaceMembersPage = 'members',
|
||||
Workspace = 'general',
|
||||
Domain = 'general/domain',
|
||||
Domains = 'domains',
|
||||
Domain = 'domains/domain',
|
||||
NewApprovedAccessDomain = 'domains/approved-access-domain/new',
|
||||
Releases = 'releases',
|
||||
AI = 'ai',
|
||||
AINewAgent = 'ai/new-agent',
|
||||
@@ -42,7 +43,6 @@ export enum SettingsPath {
|
||||
IntegrationNewDatabaseConnection = 'integrations/:databaseKey/new',
|
||||
Security = 'security',
|
||||
NewSSOIdentityProvider = 'security/sso/new',
|
||||
NewApprovedAccessDomain = 'security/approved-access-domain/new',
|
||||
|
||||
AdminPanel = 'admin-panel',
|
||||
AdminPanelHealthStatus = 'admin-panel#health-status',
|
||||
|
||||
Reference in New Issue
Block a user