0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-20 21:32:31 -05:00

fix(console): should block form submit when error in domain input field (#5050)

This commit is contained in:
Darcy Ye 2023-12-04 13:07:17 +08:00 committed by GitHub
parent d8d420c812
commit 6fe8cbc006
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 120 additions and 89 deletions

View file

@ -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) {
</InlineNotification>
)}
<FormField isRequired title="enterprise_sso.metadata.oidc.client_id_field_name">
<TextInput {...register('clientId', { required: true })} error={Boolean(errors.clientId)} />
<TextInput
{...register('clientId', { required: true })}
error={Boolean(errors.clientId)}
placeholder="Client ID"
/>
</FormField>
<FormField isRequired title="enterprise_sso.metadata.oidc.client_secret_field_name">
<TextInput
isConfidential
{...register('clientSecret', { required: true })}
error={Boolean(errors.clientSecret)}
placeholder="Client secret"
/>
</FormField>
<FormField
@ -63,8 +69,10 @@ function OidcMetadataForm({ providerConfig, config, providerName }: Props) {
<TextInput
{...register('issuer', {
required: true,
validate: (value) => !value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
error={Boolean(errors.issuer)}
error={errors.issuer?.message}
placeholder="http(s)://"
/>
)}
{providerConfig &&

View file

@ -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+');

View file

@ -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<TagProps['status'], 'info'>;
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<HTMLInputElement>(null);
const [focusedValueId, setFocusedValueId] = useState<Nullable<string>>(null);
const [currentValue, setCurrentValue] = useState('');
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { setError } = useFormContext<DomainsFormType>();
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;

View file

@ -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<TagProps['status'], 'info'>;
};
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 };
};

View file

@ -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<SsoConnector, 'branding' | 'connectorName'> & {
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<SsoConnector, 'branding' | 'connectorName'> &
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 } }) => (
<MultiInput
<DomainsInput
values={value}
// Per previous error handling on submitting, error message will be truthy.
error={errors.domains?.message}
placeholder="enterprise_sso_details.email_domain_field_placeholder"
onChange={(values) => {
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}
/>
)}
/>