From 6fe8cbc006fff3e29d32759edf3bb76bde5886cf Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Mon, 4 Dec 2023 13:07:17 +0800 Subject: [PATCH] fix(console): should block form submit when error in domain input field (#5050) --- .../Connection/OidcMetadataForm/index.tsx | 12 ++- .../Experience/DomainsInput/consts.ts | 7 ++ .../index.module.scss | 0 .../{MultiInput => DomainsInput}/index.tsx | 37 ++++---- .../Experience/DomainsInput/utils.ts | 68 +++++++++++++++ .../EnterpriseSsoDetails/Experience/index.tsx | 85 ++++--------------- 6 files changed, 120 insertions(+), 89 deletions(-) create mode 100644 packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/consts.ts rename packages/console/src/pages/EnterpriseSsoDetails/Experience/{MultiInput => DomainsInput}/index.module.scss (100%) rename packages/console/src/pages/EnterpriseSsoDetails/Experience/{MultiInput => DomainsInput}/index.tsx (83%) create mode 100644 packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/utils.ts diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Connection/OidcMetadataForm/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Connection/OidcMetadataForm/index.tsx index a52cc8bb7..2fb056fa5 100644 --- a/packages/console/src/pages/EnterpriseSsoDetails/Connection/OidcMetadataForm/index.tsx +++ b/packages/console/src/pages/EnterpriseSsoDetails/Connection/OidcMetadataForm/index.tsx @@ -11,6 +11,7 @@ import { type OidcGuideFormType, type SsoConnectorConfig, } from '@/pages/EnterpriseSso/types.js'; +import { uriValidator } from '@/utils/validator'; import ParsedConfigPreview from './ParsedConfigPreview'; import * as styles from './index.module.scss'; @@ -39,13 +40,18 @@ function OidcMetadataForm({ providerConfig, config, providerName }: Props) { )} - + !value || uriValidator(value) || t('errors.invalid_uri_format'), })} - error={Boolean(errors.issuer)} + error={errors.issuer?.message} + placeholder="http(s)://" /> )} {providerConfig && diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/consts.ts b/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/consts.ts new file mode 100644 index 000000000..f399b035f --- /dev/null +++ b/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/consts.ts @@ -0,0 +1,7 @@ +export const duplicatedDomainsErrorCode = 'single_sign_on.duplicated_domains'; +export const forbiddenDomainsErrorCode = 'single_sign_on.forbidden_domains'; +export const invalidDomainFormatErrorCode = 'single_sign_on.invalid_domain_format'; + +// RegExp to domain string. +// eslint-disable-next-line prefer-regex-literals +export const domainRegExp = new RegExp('\\S+[\\.]{1}\\S+'); diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Experience/MultiInput/index.module.scss b/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.module.scss similarity index 100% rename from packages/console/src/pages/EnterpriseSsoDetails/Experience/MultiInput/index.module.scss rename to packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.module.scss diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Experience/MultiInput/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.tsx similarity index 83% rename from packages/console/src/pages/EnterpriseSsoDetails/Experience/MultiInput/index.tsx rename to packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.tsx index c24ccc768..b15f853f2 100644 --- a/packages/console/src/pages/EnterpriseSsoDetails/Experience/MultiInput/index.tsx +++ b/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.tsx @@ -3,28 +3,20 @@ import { generateStandardShortId } from '@logto/shared/universal'; import { conditional, type Nullable } from '@silverhand/essentials'; import classNames from 'classnames'; import { useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import Close from '@/assets/icons/close.svg'; import IconButton from '@/ds-components/IconButton'; -import Tag, { type Props as TagProps } from '@/ds-components/Tag'; +import Tag from '@/ds-components/Tag'; import { onKeyDownHandler } from '@/utils/a11y'; +import { domainRegExp } from './consts'; import * as styles from './index.module.scss'; +import { domainOptionsParser, type Option } from './utils'; -export type Option = { - /** - * Generate a random unique id for each option to handle deletion. - * Sometimes we may have options with the same value, which is allowed when inputting but prohibited when submitting. - */ - id: string; - value: string; - /** - * The `status` is used to indicate the status of the domain item (could fall into following categories): - * - undefined: valid domain - * - 'info': duplicated domain or blocked domain, see {@link packages/schemas/src/utils/domain.ts}. - */ - status?: Extract; +export type DomainsFormType = { + domains: Option[]; }; type Props = { @@ -35,15 +27,20 @@ type Props = { placeholder?: AdminConsoleKey; }; -// RegExp to domain string. -// eslint-disable-next-line prefer-regex-literals -export const domainRegExp = new RegExp('\\S+[\\.]{1}\\S+'); - -function MultiInput({ className, values, onChange, error, placeholder }: Props) { +function DomainsInput({ className, values, onChange: rawOnChange, error, placeholder }: Props) { const inputRef = useRef(null); const [focusedValueId, setFocusedValueId] = useState>(null); const [currentValue, setCurrentValue] = useState(''); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { setError } = useFormContext(); + + const onChange = (values: Option[]) => { + const { values: parsedValues, errorMessage } = domainOptionsParser(values); + if (errorMessage) { + setError('domains', { type: 'custom', message: errorMessage }); + } + rawOnChange(parsedValues); + }; const handleAdd = (value: string) => { const newValues: Option[] = [ @@ -158,4 +155,4 @@ function MultiInput({ className, values, onChange, error, placeholder }: Props) ); } -export default MultiInput; +export default DomainsInput; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/utils.ts b/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/utils.ts new file mode 100644 index 000000000..76ed62dd7 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/utils.ts @@ -0,0 +1,68 @@ +import { findDuplicatedOrBlockedEmailDomains } from '@logto/schemas'; +import { conditional, conditionalArray, conditionalString } from '@silverhand/essentials'; +import { t as globalTranslate } from 'i18next'; + +import { type Props as TagProps } from '@/ds-components/Tag'; + +import { + domainRegExp, + duplicatedDomainsErrorCode, + forbiddenDomainsErrorCode, + invalidDomainFormatErrorCode, +} from './consts'; + +export type Option = { + /** + * Generate a random unique id for each option to handle deletion. + * Sometimes we may have options with the same value, which is allowed when inputting but prohibited when submitting. + */ + id: string; + value: string; + /** + * The `status` is used to indicate the status of the domain item (could fall into following categories): + * - undefined: valid domain + * - 'info': duplicated domain or blocked domain, see {@link packages/schemas/src/utils/domain.ts}. + */ + status?: Extract; +}; + +export const domainOptionsParser = ( + inputValues: Option[] +): { + values: Option[]; + errorMessage?: string; +} => { + const { duplicatedDomains, forbiddenDomains } = findDuplicatedOrBlockedEmailDomains( + inputValues.map((domain) => domain.value) + ); + const isAnyDomainInvalid = inputValues.some(({ value }) => !domainRegExp.test(value)); + + // Show error message and update the inputs' status for error display. + if (duplicatedDomains.size > 0 || forbiddenDomains.size > 0 || isAnyDomainInvalid) { + return { + values: inputValues.map(({ status, ...rest }) => ({ + ...rest, + ...conditional( + (duplicatedDomains.has(rest.value) || + forbiddenDomains.has(rest.value) || + !domainRegExp.test(rest.value)) && { + status: 'info', + } + ), + })), + errorMessage: conditionalArray( + conditionalString( + duplicatedDomains.size > 0 && globalTranslate(`errors:${duplicatedDomainsErrorCode}`) + ), + conditionalString( + forbiddenDomains.size > 0 && globalTranslate(`errors:${forbiddenDomainsErrorCode}`) + ), + conditionalString( + isAnyDomainInvalid && globalTranslate(`errors:${invalidDomainFormatErrorCode}`) + ) + ).join(' '), + }; + } + + return { values: inputValues }; +}; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Experience/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Experience/index.tsx index 4262daee0..fd64cb8e0 100644 --- a/packages/console/src/pages/EnterpriseSsoDetails/Experience/index.tsx +++ b/packages/console/src/pages/EnterpriseSsoDetails/Experience/index.tsx @@ -2,12 +2,10 @@ import { type SsoConnector, type SsoConnectorWithProviderConfig, type RequestErrorBody, - findDuplicatedOrBlockedEmailDomains, } from '@logto/schemas'; import { generateStandardShortId } from '@logto/shared/universal'; -import { conditional, conditionalArray, conditionalString } from '@silverhand/essentials'; +import { conditional } from '@silverhand/essentials'; import cleanDeep from 'clean-deep'; -import { t as globalTranslate } from 'i18next'; import { HTTPError } from 'ky'; import { useForm, Controller, FormProvider } from 'react-hook-form'; import { toast } from 'react-hot-toast'; @@ -26,8 +24,14 @@ import { SyncProfileMode } from '@/types/connector'; import { trySubmitSafe } from '@/utils/form'; import { uriValidator } from '@/utils/validator'; +import DomainsInput, { type DomainsFormType } from './DomainsInput'; +import { + duplicatedDomainsErrorCode, + forbiddenDomainsErrorCode, + invalidDomainFormatErrorCode, +} from './DomainsInput/consts'; +import { domainOptionsParser } from './DomainsInput/utils'; import LogosUploader from './LogosUploader'; -import MultiInput, { type Option as MultiInputOption, domainRegExp } from './MultiInput'; import * as styles from './index.module.scss'; type DataType = Pick< @@ -42,14 +46,10 @@ type Props = { isDarkModeEnabled: boolean; }; -export type FormType = Pick & { - syncProfile: SyncProfileMode; - domains: MultiInputOption[]; -}; - -const duplicatedDomainsErrorCode = 'single_sign_on.duplicated_domains'; -const forbiddenDomainsErrorCode = 'single_sign_on.forbidden_domains'; -const invalidDomainFormatErrorCode = 'single_sign_on.invalid_domain_format'; +export type FormType = Pick & + DomainsFormType & { + syncProfile: SyncProfileMode; + }; const duplicateConnectorNameErrorCode = 'single_sign_on.duplicate_connector_name'; @@ -192,69 +192,20 @@ function Experience({ data, isDeleted, onUpdated, isDarkModeEnabled }: Props) { if (value.length === 0) { return t('enterprise_sso_details.email_domain_field_required'); } + const { errorMessage } = domainOptionsParser(value); + if (errorMessage) { + return errorMessage; + } return true; }, }} render={({ field: { onChange, value } }) => ( - { - const { duplicatedDomains, forbiddenDomains } = - findDuplicatedOrBlockedEmailDomains(values.map((domain) => domain.value)); - const isAnyDomainInvalid = values.some( - ({ value }) => !domainRegExp.test(value) - ); - - // Show error message and update the inputs' status for error display. - if ( - duplicatedDomains.size > 0 || - forbiddenDomains.size > 0 || - isAnyDomainInvalid - ) { - onChange( - values.map(({ status, ...rest }) => ({ - ...rest, - ...conditional( - (duplicatedDomains.has(rest.value) || - forbiddenDomains.has(rest.value) || - !domainRegExp.test(rest.value)) && { - status: 'info', - } - ), - })) - ); - setError('domains', { - type: 'custom', - message: conditionalArray( - conditionalString( - duplicatedDomains.size > 0 && - globalTranslate(`errors:${duplicatedDomainsErrorCode}`) - ), - conditionalString( - forbiddenDomains.size > 0 && - globalTranslate(`errors:${forbiddenDomainsErrorCode}`) - ), - conditionalString( - isAnyDomainInvalid && - globalTranslate(`errors:${invalidDomainFormatErrorCode}`) - ) - ).join(' '), - }); - return; - } - - // Should clear the current field's error message and clear error status for input options when there is no error. - onChange( - values.map((domain) => { - const { status, ...rest } = domain; - return rest; - }) - ); - clearErrors('domains'); - }} + onChange={onChange} /> )} />