Add settings section for public domains (#14543)
As title https://github.com/user-attachments/assets/1c2adb14-0fc1-4623-88bc-10ff729c72a1
This commit is contained in:
@@ -1047,6 +1047,7 @@ export enum FeatureFlagKey {
|
||||
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
|
||||
IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED',
|
||||
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
|
||||
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
|
||||
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
|
||||
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||
@@ -2678,6 +2679,7 @@ export type Query = {
|
||||
findAgentHandoffs: Array<AgentHandoffDto>;
|
||||
findDistantTablesWithStatus: Array<RemoteTable>;
|
||||
findManyAgents: Array<Agent>;
|
||||
findManyPublicDomains: Array<PublicDomain>;
|
||||
findManyRemoteServersByType: Array<RemoteServer>;
|
||||
findManyServerlessFunctions: Array<ServerlessFunction>;
|
||||
findOneAgent: Agent;
|
||||
@@ -4805,6 +4807,32 @@ export type GetWebhooksQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
export type GetWebhooksQuery = { __typename?: 'Query', webhooks: Array<{ __typename?: 'Webhook', id: string, targetUrl: string, operations: Array<string>, description?: string | null, secret: string }> };
|
||||
|
||||
export type CheckPublicDomainValidRecordsMutationVariables = Exact<{
|
||||
domain: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type CheckPublicDomainValidRecordsMutation = { __typename?: 'Mutation', checkPublicDomainValidRecords?: { __typename?: 'DomainValidRecords', id: string, domain: string, records: Array<{ __typename?: 'DomainRecord', type: string, key: string, value: string, validationType: string, status: string }> } | null };
|
||||
|
||||
export type CreatePublicDomainMutationVariables = Exact<{
|
||||
domain: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type CreatePublicDomainMutation = { __typename?: 'Mutation', createPublicDomain: { __typename?: 'PublicDomain', id: string, domain: string, isValidated: boolean, createdAt: string } };
|
||||
|
||||
export type DeletePublicDomainMutationVariables = Exact<{
|
||||
domain: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type DeletePublicDomainMutation = { __typename?: 'Mutation', deletePublicDomain: boolean };
|
||||
|
||||
export type FindManyPublicDomainsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type FindManyPublicDomainsQuery = { __typename?: 'Query', findManyPublicDomains: Array<{ __typename?: 'PublicDomain', id: string, domain: string, isValidated: boolean, createdAt: string }> };
|
||||
|
||||
export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
|
||||
input: UpdateLabPublicFeatureFlagInput;
|
||||
}>;
|
||||
@@ -9576,6 +9604,151 @@ export function useGetWebhooksLazyQuery(baseOptions?: Apollo.LazyQueryHookOption
|
||||
export type GetWebhooksQueryHookResult = ReturnType<typeof useGetWebhooksQuery>;
|
||||
export type GetWebhooksLazyQueryHookResult = ReturnType<typeof useGetWebhooksLazyQuery>;
|
||||
export type GetWebhooksQueryResult = Apollo.QueryResult<GetWebhooksQuery, GetWebhooksQueryVariables>;
|
||||
export const CheckPublicDomainValidRecordsDocument = gql`
|
||||
mutation CheckPublicDomainValidRecords($domain: String!) {
|
||||
checkPublicDomainValidRecords(domain: $domain) {
|
||||
id
|
||||
domain
|
||||
records {
|
||||
type
|
||||
key
|
||||
value
|
||||
validationType
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type CheckPublicDomainValidRecordsMutationFn = Apollo.MutationFunction<CheckPublicDomainValidRecordsMutation, CheckPublicDomainValidRecordsMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useCheckPublicDomainValidRecordsMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useCheckPublicDomainValidRecordsMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useCheckPublicDomainValidRecordsMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [checkPublicDomainValidRecordsMutation, { data, loading, error }] = useCheckPublicDomainValidRecordsMutation({
|
||||
* variables: {
|
||||
* domain: // value for 'domain'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useCheckPublicDomainValidRecordsMutation(baseOptions?: Apollo.MutationHookOptions<CheckPublicDomainValidRecordsMutation, CheckPublicDomainValidRecordsMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<CheckPublicDomainValidRecordsMutation, CheckPublicDomainValidRecordsMutationVariables>(CheckPublicDomainValidRecordsDocument, options);
|
||||
}
|
||||
export type CheckPublicDomainValidRecordsMutationHookResult = ReturnType<typeof useCheckPublicDomainValidRecordsMutation>;
|
||||
export type CheckPublicDomainValidRecordsMutationResult = Apollo.MutationResult<CheckPublicDomainValidRecordsMutation>;
|
||||
export type CheckPublicDomainValidRecordsMutationOptions = Apollo.BaseMutationOptions<CheckPublicDomainValidRecordsMutation, CheckPublicDomainValidRecordsMutationVariables>;
|
||||
export const CreatePublicDomainDocument = gql`
|
||||
mutation CreatePublicDomain($domain: String!) {
|
||||
createPublicDomain(domain: $domain) {
|
||||
id
|
||||
domain
|
||||
isValidated
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type CreatePublicDomainMutationFn = Apollo.MutationFunction<CreatePublicDomainMutation, CreatePublicDomainMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useCreatePublicDomainMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useCreatePublicDomainMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useCreatePublicDomainMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [createPublicDomainMutation, { data, loading, error }] = useCreatePublicDomainMutation({
|
||||
* variables: {
|
||||
* domain: // value for 'domain'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useCreatePublicDomainMutation(baseOptions?: Apollo.MutationHookOptions<CreatePublicDomainMutation, CreatePublicDomainMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<CreatePublicDomainMutation, CreatePublicDomainMutationVariables>(CreatePublicDomainDocument, options);
|
||||
}
|
||||
export type CreatePublicDomainMutationHookResult = ReturnType<typeof useCreatePublicDomainMutation>;
|
||||
export type CreatePublicDomainMutationResult = Apollo.MutationResult<CreatePublicDomainMutation>;
|
||||
export type CreatePublicDomainMutationOptions = Apollo.BaseMutationOptions<CreatePublicDomainMutation, CreatePublicDomainMutationVariables>;
|
||||
export const DeletePublicDomainDocument = gql`
|
||||
mutation DeletePublicDomain($domain: String!) {
|
||||
deletePublicDomain(domain: $domain)
|
||||
}
|
||||
`;
|
||||
export type DeletePublicDomainMutationFn = Apollo.MutationFunction<DeletePublicDomainMutation, DeletePublicDomainMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useDeletePublicDomainMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useDeletePublicDomainMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useDeletePublicDomainMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [deletePublicDomainMutation, { data, loading, error }] = useDeletePublicDomainMutation({
|
||||
* variables: {
|
||||
* domain: // value for 'domain'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useDeletePublicDomainMutation(baseOptions?: Apollo.MutationHookOptions<DeletePublicDomainMutation, DeletePublicDomainMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<DeletePublicDomainMutation, DeletePublicDomainMutationVariables>(DeletePublicDomainDocument, options);
|
||||
}
|
||||
export type DeletePublicDomainMutationHookResult = ReturnType<typeof useDeletePublicDomainMutation>;
|
||||
export type DeletePublicDomainMutationResult = Apollo.MutationResult<DeletePublicDomainMutation>;
|
||||
export type DeletePublicDomainMutationOptions = Apollo.BaseMutationOptions<DeletePublicDomainMutation, DeletePublicDomainMutationVariables>;
|
||||
export const FindManyPublicDomainsDocument = gql`
|
||||
query FindManyPublicDomains {
|
||||
findManyPublicDomains {
|
||||
id
|
||||
domain
|
||||
isValidated
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useFindManyPublicDomainsQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useFindManyPublicDomainsQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useFindManyPublicDomainsQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useFindManyPublicDomainsQuery({
|
||||
* variables: {
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useFindManyPublicDomainsQuery(baseOptions?: Apollo.QueryHookOptions<FindManyPublicDomainsQuery, FindManyPublicDomainsQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<FindManyPublicDomainsQuery, FindManyPublicDomainsQueryVariables>(FindManyPublicDomainsDocument, options);
|
||||
}
|
||||
export function useFindManyPublicDomainsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FindManyPublicDomainsQuery, FindManyPublicDomainsQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<FindManyPublicDomainsQuery, FindManyPublicDomainsQueryVariables>(FindManyPublicDomainsDocument, options);
|
||||
}
|
||||
export type FindManyPublicDomainsQueryHookResult = ReturnType<typeof useFindManyPublicDomainsQuery>;
|
||||
export type FindManyPublicDomainsLazyQueryHookResult = ReturnType<typeof useFindManyPublicDomainsLazyQuery>;
|
||||
export type FindManyPublicDomainsQueryResult = Apollo.QueryResult<FindManyPublicDomainsQuery, FindManyPublicDomainsQueryVariables>;
|
||||
export const UpdateLabPublicFeatureFlagDocument = gql`
|
||||
mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) {
|
||||
updateLabPublicFeatureFlag(input: $input) {
|
||||
|
||||
@@ -1011,6 +1011,7 @@ export enum FeatureFlagKey {
|
||||
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
|
||||
IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED',
|
||||
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
|
||||
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
|
||||
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
|
||||
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||
@@ -2588,6 +2589,7 @@ export type Query = {
|
||||
findAgentHandoffTargets: Array<Agent>;
|
||||
findAgentHandoffs: Array<AgentHandoffDto>;
|
||||
findManyAgents: Array<Agent>;
|
||||
findManyPublicDomains: Array<PublicDomain>;
|
||||
findManyServerlessFunctions: Array<ServerlessFunction>;
|
||||
findOneAgent: Agent;
|
||||
findOneServerlessFunction: ServerlessFunction;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SettingsProtectedRouteWrapper } from '@/settings/components/SettingsPro
|
||||
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { PermissionFlagType } from '~/generated/graphql';
|
||||
import { SettingPublicDomain } from '@/settings/domains/components/SettingPublicDomain';
|
||||
|
||||
const SettingsGraphQLPlayground = lazy(() =>
|
||||
import(
|
||||
@@ -428,6 +429,10 @@ export const SettingsRoutes = ({
|
||||
/>
|
||||
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
|
||||
<Route path={SettingsPath.Domain} element={<SettingsDomain />} />
|
||||
<Route
|
||||
path={SettingsPath.PublicDomain}
|
||||
element={<SettingPublicDomain />}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
element={
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useCheckPublicDomainValidRecords } from '@/settings/domains/hooks/useCheckPublicDomainValidRecords';
|
||||
import { type PublicDomain } from '~/generated/graphql';
|
||||
|
||||
export const CheckPublicDomainValidRecordsEffect = ({
|
||||
publicDomain,
|
||||
}: {
|
||||
publicDomain: PublicDomain;
|
||||
}) => {
|
||||
const { checkPublicDomainRecords } = useCheckPublicDomainValidRecords();
|
||||
|
||||
useEffect(() => {
|
||||
checkPublicDomainRecords(publicDomain.domain);
|
||||
// Check public domain only needs to run once at mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { H2Title, IconReload, IconTrash } from 'twenty-ui/display';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
import { Button, ButtonGroup } from 'twenty-ui/input';
|
||||
import styled from '@emotion/styled';
|
||||
import { SettingsDomainRecords } from '@/settings/domains/components/SettingsDomainRecords';
|
||||
import { useCheckPublicDomainValidRecords } from '@/settings/domains/hooks/useCheckPublicDomainValidRecords';
|
||||
import {
|
||||
useCreatePublicDomainMutation,
|
||||
useDeletePublicDomainMutation,
|
||||
useFindManyPublicDomainsQuery,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { CheckPublicDomainValidRecordsEffect } from '@/settings/domains/components/CheckPublicDomainValidRecordsEffect';
|
||||
import { selectedPublicDomainState } from '@/settings/domains/states/selectedPublicDomainState';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useState } from 'react';
|
||||
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
|
||||
import { getDomainValidationSchema } from '@/settings/domains/utils/get-domain-validation-schema';
|
||||
|
||||
const StyledButtonGroup = styled(ButtonGroup)`
|
||||
& > :not(:first-of-type) > button {
|
||||
border-left: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
align-self: flex-start;
|
||||
`;
|
||||
|
||||
const StyledDomainFormWrapper = styled.div`
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledRecordsWrapper = styled.div`
|
||||
margin-top: ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
& > :not(:first-of-type) {
|
||||
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||
}
|
||||
`;
|
||||
|
||||
export const SettingPublicDomain = () => {
|
||||
const [selectedPublicDomain, setSelectedPublicDomain] = useRecoilState(
|
||||
selectedPublicDomainState,
|
||||
);
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigateSettings();
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const [createPublicDomain, { loading }] = useCreatePublicDomainMutation();
|
||||
|
||||
const [newPublicDomain, setNewPublicDomain] = useState<string | undefined>(
|
||||
selectedPublicDomain?.domain ?? '',
|
||||
);
|
||||
|
||||
const [newPublicDomainError, setNewPublicDomainError] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
const { refetch: refetchPublicDomains } = useFindManyPublicDomainsQuery();
|
||||
|
||||
const [deletePublicDomain] = useDeletePublicDomainMutation();
|
||||
|
||||
const { isLoading, publicDomainRecords, checkPublicDomainRecords } =
|
||||
useCheckPublicDomainValidRecords();
|
||||
|
||||
const onDelete = async () => {
|
||||
if (!selectedPublicDomain) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deletePublicDomain({
|
||||
variables: { domain: selectedPublicDomain.domain },
|
||||
onCompleted: () => {
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Public domain successfully deleted`,
|
||||
});
|
||||
navigate(SettingsPath.Domains);
|
||||
refetchPublicDomains();
|
||||
},
|
||||
onError: (error) =>
|
||||
enqueueErrorSnackBar({
|
||||
apolloError: error,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const validationSchema = getDomainValidationSchema(t);
|
||||
|
||||
const onCreate = async () => {
|
||||
if (!isDefined(newPublicDomain)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = validationSchema.safeParse(newPublicDomain);
|
||||
|
||||
if (!result.success) {
|
||||
setNewPublicDomainError(result.error?.issues[0].message);
|
||||
return;
|
||||
}
|
||||
|
||||
setNewPublicDomainError(undefined);
|
||||
|
||||
await createPublicDomain({
|
||||
variables: { domain: newPublicDomain },
|
||||
onCompleted: (data) => {
|
||||
setSelectedPublicDomain(data.createPublicDomain);
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Public domain created successfully`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setNewPublicDomainError(error.message);
|
||||
enqueueErrorSnackBar({
|
||||
apolloError: error,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={t`Public domain`}
|
||||
links={[
|
||||
{
|
||||
children: <Trans>Workspace</Trans>,
|
||||
href: getSettingsPath(SettingsPath.Workspace),
|
||||
},
|
||||
{
|
||||
children: <Trans>Domains</Trans>,
|
||||
href: getSettingsPath(SettingsPath.Domains),
|
||||
},
|
||||
{ children: <Trans>Public Domain</Trans> },
|
||||
]}
|
||||
actionButton={
|
||||
<SaveAndCancelButtons
|
||||
onCancel={() => navigate(SettingsPath.Domains)}
|
||||
isSaveDisabled={loading || isDefined(selectedPublicDomain)}
|
||||
onSave={onCreate}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Public domain`}
|
||||
description={t`Set the name of your public domain and configure your DNS records.`}
|
||||
/>
|
||||
{isDefined(selectedPublicDomain) && (
|
||||
<CheckPublicDomainValidRecordsEffect
|
||||
publicDomain={selectedPublicDomain}
|
||||
/>
|
||||
)}
|
||||
<StyledDomainFormWrapper>
|
||||
<TextInput
|
||||
value={newPublicDomain}
|
||||
onChange={setNewPublicDomain}
|
||||
error={newPublicDomainError}
|
||||
type="text"
|
||||
disabled={isDefined(selectedPublicDomain)}
|
||||
placeholder="crm.yourPublicDomain.com"
|
||||
fullWidth
|
||||
/>
|
||||
{isDefined(selectedPublicDomain) && (
|
||||
<StyledButtonGroup>
|
||||
<StyledButton
|
||||
isLoading={isLoading}
|
||||
Icon={IconReload}
|
||||
title={t`Reload`}
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
checkPublicDomainRecords(selectedPublicDomain.domain)
|
||||
}
|
||||
type="button"
|
||||
/>
|
||||
<StyledButton
|
||||
Icon={IconTrash}
|
||||
variant="primary"
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</StyledButtonGroup>
|
||||
)}
|
||||
</StyledDomainFormWrapper>
|
||||
{isDefined(selectedPublicDomain) && publicDomainRecords?.domain && (
|
||||
<StyledRecordsWrapper>
|
||||
{publicDomainRecords.records && (
|
||||
<SettingsDomainRecords records={publicDomainRecords.records} />
|
||||
)}
|
||||
</StyledRecordsWrapper>
|
||||
)}
|
||||
</Section>
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
);
|
||||
};
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { type PublicDomain } from '~/generated/graphql';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import {
|
||||
useDeletePublicDomainMutation,
|
||||
useFindManyPublicDomainsQuery,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { LightIconButton } from 'twenty-ui/input';
|
||||
import { IconDotsVertical, IconTrash } from 'twenty-ui/display';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
|
||||
export const SettingPublicDomainRowDropdownMenu = ({
|
||||
publicDomain,
|
||||
}: {
|
||||
publicDomain: PublicDomain;
|
||||
}) => {
|
||||
const dropdownId = `settings-public-domain-row-${publicDomain.id}`;
|
||||
const { t } = useLingui();
|
||||
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
|
||||
const { refetch: refetchPublicDomains } = useFindManyPublicDomainsQuery();
|
||||
|
||||
const [deletePublicDomain] = useDeletePublicDomainMutation();
|
||||
|
||||
const handleDeletePublicDomain = async () => {
|
||||
await deletePublicDomain({
|
||||
variables: {
|
||||
domain: publicDomain.domain,
|
||||
},
|
||||
onCompleted: () =>
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Public domain successfully deleted`,
|
||||
}),
|
||||
onError: (error) => enqueueErrorSnackBar({ apolloError: error }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownPlacement="right-start"
|
||||
clickableComponent={
|
||||
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
|
||||
}
|
||||
dropdownComponents={
|
||||
<DropdownContent>
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItem
|
||||
accent="danger"
|
||||
LeftIcon={IconTrash}
|
||||
text={t`Delete`}
|
||||
onClick={async () => {
|
||||
await handleDeletePublicDomain();
|
||||
closeDropdown(dropdownId);
|
||||
await refetchPublicDomains();
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+3
-5
@@ -8,8 +8,8 @@ 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 { SettingsDomainRecords } from '@/settings/domains/components/SettingsDomainRecords';
|
||||
import { CheckCustomDomainValidRecordsEffect } from '@/settings/domains/components/CheckCustomDomainValidRecordsEffect';
|
||||
import { useCheckCustomDomainValidRecords } from '@/settings/domains/hooks/useCheckCustomDomainValidRecords';
|
||||
import { customDomainRecordsState } from '@/settings/domains/states/customDomainRecordsState';
|
||||
|
||||
@@ -99,9 +99,7 @@ export const SettingsCustomDomain = () => {
|
||||
{currentWorkspace?.customDomain && (
|
||||
<StyledRecordsWrapper>
|
||||
{customDomainRecords && (
|
||||
<SettingsCustomDomainRecords
|
||||
records={customDomainRecords.records}
|
||||
/>
|
||||
<SettingsDomainRecords records={customDomainRecords.records} />
|
||||
)}
|
||||
</StyledRecordsWrapper>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ const StyledButton = styled(Button)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SettingsCustomDomainRecords = ({
|
||||
export const SettingsDomainRecords = ({
|
||||
records,
|
||||
}: {
|
||||
records: DomainValidRecords['records'];
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { IconAt, IconMailCog, Status } from 'twenty-ui/display';
|
||||
import { useFindManyPublicDomainsQuery } from '~/generated-metadata/graphql';
|
||||
import { SettingsListCard } from '@/settings/components/SettingsListCard';
|
||||
import { type PublicDomain } from '~/generated/graphql';
|
||||
import { SettingPublicDomainRowDropdownMenu } from '@/settings/domains/components/SettingPublicDomainRowDropdownMenu';
|
||||
import { selectedPublicDomainState } from '@/settings/domains/states/selectedPublicDomainState';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { SettingsCard } from '@/settings/components/SettingsCard';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const StyledLink = styled(Link)`
|
||||
text-decoration: none;
|
||||
`;
|
||||
|
||||
export const SettingsPublicDomainsListCard = () => {
|
||||
const navigate = useNavigateSettings();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const setSelectedPublicDomain = useSetRecoilState(selectedPublicDomainState);
|
||||
|
||||
const { data, loading } = useFindManyPublicDomainsQuery();
|
||||
|
||||
const publicDomains = data?.findManyPublicDomains;
|
||||
|
||||
if (loading || !publicDomains) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (publicDomains.length === 0) {
|
||||
setSelectedPublicDomain(undefined);
|
||||
return (
|
||||
<StyledLink to={getSettingsPath(SettingsPath.PublicDomain)}>
|
||||
<SettingsCard title={t`Add Public Domain`} Icon={<IconMailCog />} />
|
||||
</StyledLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsListCard
|
||||
items={publicDomains}
|
||||
getItemLabel={({ domain }) => domain}
|
||||
getItemDescription={({ createdAt }) => createdAt}
|
||||
RowIcon={IconAt}
|
||||
onRowClick={(publicDomain: PublicDomain) => {
|
||||
setSelectedPublicDomain(publicDomain);
|
||||
navigate(SettingsPath.PublicDomain);
|
||||
}}
|
||||
RowRightComponent={({ item: publicDomain }) => (
|
||||
<>
|
||||
{!publicDomain.isValidated && (
|
||||
<Status color="orange" text="Pending" />
|
||||
)}
|
||||
<SettingPublicDomainRowDropdownMenu publicDomain={publicDomain} />
|
||||
</>
|
||||
)}
|
||||
hasFooter
|
||||
footerButtonLabel="Add Public Domain"
|
||||
onFooterButtonClick={() => {
|
||||
setSelectedPublicDomain(undefined);
|
||||
navigate(SettingsPath.PublicDomain);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CHECK_PUBLIC_DOMAIN_VALID_RECORDS = gql`
|
||||
mutation CheckPublicDomainValidRecords($domain: String!) {
|
||||
checkPublicDomainValidRecords(domain: $domain) {
|
||||
id
|
||||
domain
|
||||
records {
|
||||
type
|
||||
key
|
||||
value
|
||||
validationType
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CREATE_PUBLIC_DOMAIN = gql`
|
||||
mutation CreatePublicDomain($domain: String!) {
|
||||
createPublicDomain(domain: $domain) {
|
||||
id
|
||||
domain
|
||||
isValidated
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const DELETE_PUBLIC_DOMAIN = gql`
|
||||
mutation DeletePublicDomain($domain: String!) {
|
||||
deletePublicDomain(domain: $domain)
|
||||
}
|
||||
`;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const FIND_MANY_PUBLIC_DOMAINS = gql`
|
||||
query FindManyPublicDomains {
|
||||
findManyPublicDomains {
|
||||
id
|
||||
domain
|
||||
isValidated
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { publicDomainRecordsState } from '@/settings/domains/states/publicDomainRecordsState';
|
||||
import { useCheckPublicDomainValidRecordsMutation } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useCheckPublicDomainValidRecords = () => {
|
||||
const [checkPublicDomainValidRecords] =
|
||||
useCheckPublicDomainValidRecordsMutation();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const [{ isLoading, publicDomainRecords }, setPublicDomainRecords] =
|
||||
useRecoilState(publicDomainRecordsState);
|
||||
|
||||
const checkPublicDomainRecords = (domain: string) => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
setPublicDomainRecords((currentState) => ({
|
||||
...currentState,
|
||||
isLoading: true,
|
||||
}));
|
||||
checkPublicDomainValidRecords({
|
||||
variables: {
|
||||
domain,
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
setPublicDomainRecords((currentState) => ({
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
...(isDefined(data.checkPublicDomainValidRecords)
|
||||
? { publicDomainRecords: data.checkPublicDomainValidRecords }
|
||||
: {}),
|
||||
}));
|
||||
},
|
||||
onError: (error) => {
|
||||
enqueueErrorSnackBar({ apolloError: error });
|
||||
setPublicDomainRecords((currentState) => ({
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
}));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
publicDomainRecords,
|
||||
checkPublicDomainRecords,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type DomainValidRecords } from '~/generated/graphql';
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
|
||||
export const publicDomainRecordsState = createState<{
|
||||
publicDomainRecords: DomainValidRecords | null;
|
||||
isLoading: boolean;
|
||||
}>({
|
||||
key: 'publicDomainRecordsState',
|
||||
defaultValue: { isLoading: false, publicDomainRecords: null },
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type PublicDomain } from '~/generated/graphql';
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
|
||||
export const selectedPublicDomainState = createState<PublicDomain | undefined>({
|
||||
key: 'selectedPublicDomainState',
|
||||
defaultValue: undefined,
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
import { type useLingui } from '@lingui/react/macro';
|
||||
|
||||
export const getDomainValidationSchema = (
|
||||
t: ReturnType<typeof useLingui>['t'],
|
||||
) =>
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^([a-zA-Z0-9][a-zA-Z0-9-]*\.)+[a-zA-Z0-9][a-zA-Z0-9-]*\.[a-zA-Z]{2,}$/,
|
||||
{
|
||||
message: t`Invalid domain. Please include at least one subdomain (e.g., sub.example.com).`,
|
||||
},
|
||||
)
|
||||
.regex(
|
||||
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/,
|
||||
{
|
||||
message: t`Invalid domain. Domains have to be smaller than 256 characters in length, cannot be IP addresses, cannot contain spaces, cannot contain any special characters such as _~\`!@#$%^*()=+{}[]|\\;:'",<>/? and cannot begin or end with a '-' character.`,
|
||||
},
|
||||
)
|
||||
.max(256)
|
||||
.optional()
|
||||
.or(z.literal(''));
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
import { type useLingui } from '@lingui/react/macro';
|
||||
|
||||
export const getSubdomainValidationSchema = (
|
||||
t: ReturnType<typeof useLingui>['t'],
|
||||
) =>
|
||||
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]$/, {
|
||||
message: t`Use letter, number and dash only. Start and finish with a letter or a number`,
|
||||
});
|
||||
@@ -22,6 +22,8 @@ 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/get-subdomain-validation-schema';
|
||||
import { getDomainValidationSchema } from '@/settings/domains/utils/get-domain-validation-schema';
|
||||
|
||||
export const SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID =
|
||||
'subdomain-change-confirmation-modal';
|
||||
@@ -32,30 +34,8 @@ export const SettingsDomain = () => {
|
||||
|
||||
const validationSchema = z
|
||||
.object({
|
||||
subdomain: 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]$/, {
|
||||
message: t`Use letter, number and dash only. Start and finish with a letter or a number`,
|
||||
}),
|
||||
customDomain: z
|
||||
.string()
|
||||
.regex(
|
||||
/^([a-zA-Z0-9][a-zA-Z0-9-]*\.)+[a-zA-Z0-9][a-zA-Z0-9-]*\.[a-zA-Z]{2,}$/,
|
||||
{
|
||||
message: t`Invalid custom domain. Please include at least one subdomain (e.g., sub.example.com).`,
|
||||
},
|
||||
)
|
||||
.regex(
|
||||
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/,
|
||||
{
|
||||
message: t`Invalid domain. Domains have to be smaller than 256 characters in length, cannot be IP addresses, cannot contain spaces, cannot contain any special characters such as _~\`!@#$%^*()=+{}[]|\\;:'",<>/? and cannot begin or end with a '-' character.`,
|
||||
},
|
||||
)
|
||||
.max(256)
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
subdomain: getSubdomainValidationSchema(t),
|
||||
customDomain: getDomainValidationSchema(t),
|
||||
})
|
||||
.required();
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { SettingsPublicDomainsListCard } from '@/settings/domains/components/SettingsPublicDomainsListCard';
|
||||
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
|
||||
import { FeatureFlagKey } from '~/generated/graphql';
|
||||
|
||||
const StyledMainContent = styled.div`
|
||||
display: flex;
|
||||
@@ -23,6 +26,10 @@ const StyledSection = styled(Section)`
|
||||
|
||||
export const SettingsDomains = () => {
|
||||
const { t } = useLingui();
|
||||
const featureFlags = useFeatureFlagsMap();
|
||||
|
||||
const isPublicDomainEnabled =
|
||||
featureFlags[FeatureFlagKey.IS_PUBLIC_DOMAIN_ENABLED];
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
@@ -51,6 +58,15 @@ export const SettingsDomains = () => {
|
||||
/>
|
||||
<SettingsApprovedAccessDomainsListCard />
|
||||
</StyledSection>
|
||||
{isPublicDomainEnabled && (
|
||||
<StyledSection>
|
||||
<H2Title
|
||||
title={t`Public Domains`}
|
||||
description={t`Provision a complete and secure hosting environment on these domains.`}
|
||||
/>
|
||||
<SettingsPublicDomainsListCard />
|
||||
</StyledSection>
|
||||
)}
|
||||
</StyledMainContent>
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
|
||||
+1
@@ -17,4 +17,5 @@ export enum FeatureFlagKey {
|
||||
IS_DATABASE_EVENT_TRIGGER_ENABLED = 'IS_DATABASE_EVENT_TRIGGER_ENABLED',
|
||||
IS_CALENDAR_VIEW_ENABLED = 'IS_CALENDAR_VIEW_ENABLED',
|
||||
IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED',
|
||||
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
|
||||
}
|
||||
|
||||
+10
-1
@@ -1,4 +1,4 @@
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
import { Query, Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
@@ -30,6 +30,15 @@ export class PublicDomainResolver {
|
||||
private readonly publicDomainService: PublicDomainService,
|
||||
) {}
|
||||
|
||||
@Query(() => [PublicDomainDTO])
|
||||
async findManyPublicDomains(
|
||||
@AuthWorkspace() currentWorkspace: Workspace,
|
||||
): Promise<PublicDomainDTO[]> {
|
||||
return await this.publicDomainRepository.find({
|
||||
where: { workspaceId: currentWorkspace.id },
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => PublicDomainDTO)
|
||||
async createPublicDomain(
|
||||
@Args() { domain }: PublicDomainInput,
|
||||
|
||||
+1
@@ -140,6 +140,7 @@ describe('WorkspaceEntityManager', () => {
|
||||
IS_DATABASE_EVENT_TRIGGER_ENABLED: false,
|
||||
IS_CALENDAR_VIEW_ENABLED: false,
|
||||
IS_GROUP_BY_ENABLED: false,
|
||||
IS_PUBLIC_DOMAIN_ENABLED: false,
|
||||
},
|
||||
eventEmitterService: {
|
||||
emitMutationEvent: jest.fn(),
|
||||
|
||||
+5
@@ -80,6 +80,11 @@ export const seedFeatureFlags = async (
|
||||
workspaceId: workspaceId,
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IS_PUBLIC_DOMAIN_ENABLED,
|
||||
workspaceId: workspaceId,
|
||||
value: false,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ export enum SettingsPath {
|
||||
Workspace = 'general',
|
||||
Domains = 'domains',
|
||||
Domain = 'domains/domain',
|
||||
PublicDomain = 'domains/public-domain',
|
||||
NewApprovedAccessDomain = 'domains/approved-access-domain/new',
|
||||
Releases = 'releases',
|
||||
AI = 'ai',
|
||||
|
||||
Reference in New Issue
Block a user