diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 2df446d44..26d07753a 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -34,7 +34,10 @@ export type ActivateWorkspaceInput = { export type AdminPanelHealthServiceData = { __typename?: 'AdminPanelHealthServiceData'; + description: Scalars['String']['output']; details?: Maybe; + id: Scalars['String']['output']; + label: Scalars['String']['output']; queues?: Maybe>; status: AdminPanelHealthServiceStatus; }; @@ -44,17 +47,11 @@ export enum AdminPanelHealthServiceStatus { OUTAGE = 'OUTAGE' } -export enum AdminPanelIndicatorHealthStatusInputEnum { - DATABASE = 'DATABASE', - MESSAGE_SYNC = 'MESSAGE_SYNC', - REDIS = 'REDIS', - WORKER = 'WORKER' -} - export type AdminPanelWorkerQueueHealth = { __typename?: 'AdminPanelWorkerQueueHealth'; + id: Scalars['String']['output']; metrics: WorkerQueueMetrics; - name: Scalars['String']['output']; + queueName: Scalars['String']['output']; status: AdminPanelHealthServiceStatus; workers: Scalars['Float']['output']; }; @@ -707,6 +704,13 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']['input']; }; +export enum HealthIndicatorId { + connectedAccount = 'connectedAccount', + database = 'database', + redis = 'redis', + worker = 'worker' +} + export enum IdentityProviderType { OIDC = 'OIDC', SAML = 'SAML' @@ -1506,7 +1510,7 @@ export type QueryGetAvailablePackagesArgs = { export type QueryGetIndicatorHealthStatusArgs = { - indicatorName: AdminPanelIndicatorHealthStatusInputEnum; + indicatorId: HealthIndicatorId; }; @@ -1871,10 +1875,14 @@ export type Support = { export type SystemHealth = { __typename?: 'SystemHealth'; - database: AdminPanelHealthServiceData; - messageSync: AdminPanelHealthServiceData; - redis: AdminPanelHealthServiceData; - worker: AdminPanelHealthServiceData; + services: Array; +}; + +export type SystemHealthService = { + __typename?: 'SystemHealthService'; + id: HealthIndicatorId; + label: Scalars['String']['output']; + status: AdminPanelHealthServiceStatus; }; export type TimelineCalendarEvent = { diff --git a/packages/twenty-front/src/modules/settings/components/SettingsCard.tsx b/packages/twenty-front/src/modules/settings/components/SettingsCard.tsx index 3505d30c0..5d2b100f3 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsCard.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsCard.tsx @@ -12,6 +12,7 @@ type SettingsCardProps = { onClick?: () => void; title: string; className?: string; + Status?: ReactNode; }; const StyledCard = styled(Card)<{ @@ -77,6 +78,7 @@ export const SettingsCard = ({ onClick, title, className, + Status, }: SettingsCardProps) => { const theme = useTheme(); @@ -94,6 +96,7 @@ export const SettingsCard = ({ {title} {soon && } + {Status && Status} {description && {description}} diff --git a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx index 56d6272a0..095373731 100644 --- a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx @@ -11,7 +11,13 @@ import { useRef, useState, } from 'react'; -import { AutogrowWrapper, IconComponent, IconEye, IconEyeOff } from 'twenty-ui'; +import { + AutogrowWrapper, + IconComponent, + IconEye, + IconEyeOff, + Loader, +} from 'twenty-ui'; import { useCombinedRefs } from '~/hooks/useCombinedRefs'; import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly'; @@ -158,6 +164,7 @@ export type TextInputV2ComponentProps = Omit< dataTestId?: string; sizeVariant?: TextInputV2Size; inheritFontStyles?: boolean; + loading?: boolean; }; type TextInputV2WithAutoGrowWrapperProps = TextInputV2ComponentProps; @@ -193,6 +200,7 @@ const TextInputV2Component = forwardRef< inheritFontStyles = false, dataTestId, autoGrow = false, + loading = false, }, ref, ) => { @@ -284,6 +292,12 @@ const TextInputV2Component = forwardRef< )} + + {!error && type !== INPUT_TYPE_PASSWORD && !!loading && ( + + + + )} {error} diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx index 65390d2e7..c7cb7ab74 100644 --- a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx @@ -1,6 +1,12 @@ import { useLingui } from '@lingui/react/macro'; import { useRecoilValue } from 'recoil'; -import { H2Title, IconWorld, Section, UndecoratedLink } from 'twenty-ui'; +import { + H2Title, + IconWorld, + Section, + UndecoratedLink, + Status, +} from 'twenty-ui'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { SettingsCard } from '@/settings/components/SettingsCard'; @@ -12,11 +18,12 @@ import { WorkspaceLogoUploader } from '@/settings/workspace/components/Workspace import { SettingsPath } from '@/types/SettingsPath'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; export const SettingsWorkspace = () => { const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); const { t } = useLingui(); - + const currentWorkspace = useRecoilValue(currentWorkspaceState); return ( { } + Status={ + currentWorkspace?.customDomain ? ( + + ) : undefined + } /> diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomain.tsx index c11aa3d05..e76a2d3c7 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomain.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomain.tsx @@ -17,16 +17,26 @@ const StyledDomainFormWrapper = styled.div` 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 = useRecoilValue(customDomainRecordsState); +export const SettingsCustomDomain = ({ + handleSave, +}: { + handleSave: () => void; +}) => { + const { customDomainRecords, loading } = useRecoilValue( + customDomainRecordsState, + ); const currentWorkspace = useRecoilValue(currentWorkspaceState); const { t } = useLingui(); - const { control } = useFormContext<{ + const { control, handleSubmit } = useFormContext<{ customDomain: string; }>(); @@ -45,24 +55,29 @@ export const SettingsCustomDomain = () => { value={value} type="text" onChange={onChange} + placeholder="crm.yourdomain.com" error={error?.message} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSubmit(handleSave); + } + }} + loading={!!loading} fullWidth /> )} /> - {customDomainRecords && - currentWorkspace?.customDomain && - currentWorkspace.customDomain === customDomainRecords?.customDomain && ( - - + {currentWorkspace?.customDomain && ( + + + {customDomainRecords && ( - - )} + )} + + )} ); }; diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainEffect.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainEffect.tsx index 7d185b915..e007e40e7 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainEffect.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainEffect.tsx @@ -6,32 +6,35 @@ import { useCheckCustomDomainValidRecordsMutation } from '~/generated/graphql'; import { customDomainRecordsState } from '~/pages/settings/workspace/states/customDomainRecordsState'; export const SettingsCustomDomainEffect = () => { - const [checkCustomDomainValidRecords, { data: customDomainRecords }] = + const [checkCustomDomainValidRecords] = useCheckCustomDomainValidRecordsMutation(); const setCustomDomainRecords = useSetRecoilState(customDomainRecordsState); const currentWorkspace = useRecoilValue(currentWorkspaceState); - const initInterval = useCallback(() => { - return setInterval(async () => { - await checkCustomDomainValidRecords(); - if (isDefined(customDomainRecords?.checkCustomDomainValidRecords)) { - setCustomDomainRecords( - customDomainRecords.checkCustomDomainValidRecords, - ); - } - }, 3000); - }, [ - checkCustomDomainValidRecords, - customDomainRecords, - setCustomDomainRecords, - ]); + const checkCustomDomainValidRecordsPolling = useCallback(async () => { + setCustomDomainRecords((currentState) => ({ + ...currentState, + loading: true, + })); + checkCustomDomainValidRecords({ + onCompleted: (data) => { + if (isDefined(data.checkCustomDomainValidRecords)) { + setCustomDomainRecords({ + loading: false, + customDomainRecords: data.checkCustomDomainValidRecords, + }); + } + }, + }); + }, [checkCustomDomainValidRecords, setCustomDomainRecords]); useEffect(() => { let pollIntervalFn: null | ReturnType = null; if (isDefined(currentWorkspace?.customDomain)) { - pollIntervalFn = initInterval(); + checkCustomDomainValidRecordsPolling(); + pollIntervalFn = setInterval(checkCustomDomainValidRecordsPolling, 6000); } return () => { @@ -39,7 +42,7 @@ export const SettingsCustomDomainEffect = () => { clearInterval(pollIntervalFn); } }; - }, [currentWorkspace?.customDomain, initInterval]); + }, [checkCustomDomainValidRecordsPolling, currentWorkspace?.customDomain]); return <>; }; diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecords.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecords.tsx index f66837806..90048a4e1 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecords.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecords.tsx @@ -29,7 +29,7 @@ const StyledButton = styled(Button)` color: ${({ theme }) => theme.font.color.tertiary}; font-family: ${({ theme }) => theme.font.family}; font-weight: ${({ theme }) => theme.font.weight.regular}; - height: ${({ theme }) => theme.spacing(7)}; + height: ${({ theme }) => theme.spacing(6)}; overflow: hidden; user-select: text; width: 100%; @@ -61,32 +61,30 @@ export const SettingsCustomDomainRecords = ({ Value - {records.map((record) => { - return ( - - - copyToClipboardDebounced(record.key)} - /> - - - - copyToClipboardDebounced(record.type.toUpperCase()) - } - /> - - - copyToClipboardDebounced(record.value)} - /> - - - ); - })} + {records.map((record) => ( + + + copyToClipboardDebounced(record.key)} + /> + + + + copyToClipboardDebounced(record.type.toUpperCase()) + } + /> + + + copyToClipboardDebounced(record.value)} + /> + + + ))} ); diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecordsStatus.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecordsStatus.tsx index 6a7b843c5..902dbf38e 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecordsStatus.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsCustomDomainRecordsStatus.tsx @@ -3,74 +3,85 @@ import { TableRow } from '@/ui/layout/table/components/TableRow'; import { TableCell } from '@/ui/layout/table/components/TableCell'; import { Status, ThemeColor } from 'twenty-ui'; import styled from '@emotion/styled'; -import { CustomDomainValidRecords } from '~/generated/graphql'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { customDomainRecordsState } from '~/pages/settings/workspace/states/customDomainRecordsState'; +import { useRecoilValue } from 'recoil'; +import { capitalize } from 'twenty-shared'; const StyledTable = styled(Table)` background-color: ${({ theme }) => theme.background.transparent.lighter}; - border-radius: ${({ theme }) => theme.border.radius.sm}; border: 1px solid ${({ theme }) => theme.border.color.light}; + padding: 0 ${({ theme }) => theme.spacing(2)}; + border-radius: ${({ theme }) => theme.border.radius.sm}; `; const StyledTableRow = styled(TableRow)` display: flex; - border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; + border-bottom: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: 0; align-items: center; justify-content: space-between; + padding: 0; + font-size: ${({ theme }) => theme.font.size.sm}; &:last-child { border-bottom: none; } `; -export const SettingsCustomDomainRecordsStatus = ({ - records, -}: { - records: CustomDomainValidRecords['records']; -}) => { - const rows = records.reduce( - (acc, record) => { - acc[record.validationType] = { - name: acc[record.validationType].name, - status: record.status, +const StyledTableCell = styled(TableCell)` + padding: 0; +`; + +const records = [ + { name: 'CNAME', validationType: 'redirection' as const }, + { name: 'TXT Validation', validationType: 'ownership' as const }, + { name: 'SSL Certificate Generation', validationType: 'ssl' as const }, +]; + +export const SettingsCustomDomainRecordsStatus = () => { + const currentWorkspace = useRecoilValue(currentWorkspaceState); + + const { customDomainRecords } = useRecoilValue(customDomainRecordsState); + + const defaultValues: { status: string; color: ThemeColor } = + currentWorkspace?.customDomain === customDomainRecords?.customDomain + ? { + status: 'success', + color: 'green', + } + : { + status: 'loading', + color: 'gray', + }; + + const rows = records.map<{ name: string; status: string; color: ThemeColor }>( + (record) => { + const foundRecord = customDomainRecords?.records.find( + ({ validationType }) => validationType === record.validationType, + ); + return { + name: record.name, + status: foundRecord ? foundRecord.status : defaultValues.status, color: - record.status === 'error' + foundRecord && foundRecord.status === 'error' ? 'red' - : record.status === 'pending' + : foundRecord && foundRecord.status === 'pending' ? 'yellow' - : 'green', + : defaultValues.color, }; - return acc; }, - { - ssl: { - name: 'SSL', - status: 'success', - color: 'green', - }, - redirection: { - name: 'Redirection', - status: 'success', - color: 'green', - }, - ownership: { - name: 'Ownership', - status: 'success', - color: 'green', - }, - } as Record, ); return ( - {Object.values(rows).map((row) => { - return ( - - {row.name} - - - - - ); - })} + {rows.map((row) => ( + + {row.name} + + + + + ))} ); }; diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx index 107bf63ac..ef2e3e1c6 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx @@ -42,6 +42,12 @@ export const SettingsDomain = () => { }), 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])$/, { @@ -70,7 +76,7 @@ export const SettingsDomain = () => { subdomain: string; customDomain: string | null; }>({ - mode: 'onChange', + mode: 'onSubmit', delayError: 500, defaultValues: { subdomain: currentWorkspace?.subdomain ?? '', @@ -169,6 +175,15 @@ export const SettingsDomain = () => { }); } + if ( + subdomainValue === currentWorkspace?.subdomain && + customDomainValue === currentWorkspace?.customDomain + ) { + return enqueueSnackBar(t`No change detected`, { + variant: SnackBarVariant.Error, + }); + } + if ( isDefined(values.subdomain) && values.subdomain !== currentWorkspace.subdomain @@ -197,24 +212,19 @@ export const SettingsDomain = () => { ]} actionButton={ navigate(SettingsPath.Workspace)} - onSave={handleSave} + onSave={form.handleSubmit(handleSave)} /> } > {/* eslint-disable-next-line react/jsx-props-no-spreading */} - + {isCustomDomainEnabled && ( <> - + )} diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsSubdomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsSubdomain.tsx index dfc725c66..752e16324 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsSubdomain.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsSubdomain.tsx @@ -23,13 +23,17 @@ const StyledDomain = styled.h2` white-space: nowrap; `; -export const SettingsSubdomain = () => { +export const SettingsSubdomain = ({ + handleSave, +}: { + handleSave: () => void; +}) => { const domainConfiguration = useRecoilValue(domainConfigurationState); const { t } = useLingui(); const currentWorkspace = useRecoilValue(currentWorkspaceState); - const { control } = useFormContext<{ + const { control, handleSubmit } = useFormContext<{ subdomain: string; }>(); @@ -52,6 +56,11 @@ export const SettingsSubdomain = () => { error={error?.message} disabled={!!currentWorkspace?.customDomain} fullWidth + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSubmit(handleSave); + } + }} /> {isDefined(domainConfiguration.frontDomain) && ( diff --git a/packages/twenty-front/src/pages/settings/workspace/states/customDomainRecordsState.ts b/packages/twenty-front/src/pages/settings/workspace/states/customDomainRecordsState.ts index 2068d8b1c..e2a1a0793 100644 --- a/packages/twenty-front/src/pages/settings/workspace/states/customDomainRecordsState.ts +++ b/packages/twenty-front/src/pages/settings/workspace/states/customDomainRecordsState.ts @@ -1,8 +1,10 @@ import { createState } from '@ui/utilities/state/utils/createState'; import { CustomDomainValidRecords } from '~/generated/graphql'; -export const customDomainRecordsState = - createState({ - key: 'customDomainRecordsState', - defaultValue: null, - }); +export const customDomainRecordsState = createState<{ + customDomainRecords: CustomDomainValidRecords | null; + loading: boolean; +}>({ + key: 'customDomainRecordsState', + defaultValue: { loading: false, customDomainRecords: null }, +}); diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts index e3274c4dd..7983b5620 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/services/custom-domain.service.ts @@ -81,8 +81,6 @@ export class CustomDomainService { ] .map( (record: Record) => { - if (!record) return; - if ( 'txt_name' in record && 'txt_value' in record && @@ -92,7 +90,11 @@ export class CustomDomainService { return { validationType: 'ssl' as const, type: 'txt' as const, - status: response.result[0].ssl.status ?? 'pending', + status: + !response.result[0].ssl.status || + response.result[0].ssl.status.startsWith('pending') + ? 'pending' + : response.result[0].ssl.status, key: record.txt_name, value: record.txt_value, }; @@ -120,10 +122,16 @@ export class CustomDomainService { validationType: 'redirection' as const, type: 'cname' as const, status: - response.result[0].verification_errors?.[0] === - 'custom hostname does not CNAME to this zone.' - ? 'error' - : 'success', + // wait 10s before starting the real check + response.result[0].created_at && + new Date().getTime() - + new Date(response.result[0].created_at).getTime() < + 1000 * 10 + ? 'pending' + : response.result[0].verification_errors?.[0] === + 'custom hostname does not CNAME to this zone.' + ? 'error' + : 'success', key: response.result[0].hostname, value: this.domainManagerService.getFrontUrl().hostname, },