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:
parent
d8d420c812
commit
6fe8cbc006
6 changed files with 120 additions and 89 deletions
|
@ -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 &&
|
||||
|
|
|
@ -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+');
|
|
@ -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;
|
|
@ -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 };
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
Loading…
Add table
Reference in a new issue