0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

refactor(console): form validations on sign-up and sign-in page (#2448)

This commit is contained in:
Xiao Yijun 2022-11-16 18:48:33 +08:00 committed by GitHub
parent 0af95d790b
commit 62b25dbf14
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 286 additions and 312 deletions

View file

@ -1,33 +1,13 @@
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import FormField from '@/components/FormField';
import type { SignInExperienceForm } from '../../types';
import SignInMethodEditBox from './components/SignInMethodEditBox';
import {
signUpIdentifierToRequiredConnectorMapping,
signUpToSignInIdentifierMapping,
} from './constants';
import * as styles from './index.module.scss';
const SignInForm = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { control, watch } = useFormContext<SignInExperienceForm>();
const signUpIdentifier = watch('signUp.identifier');
const setupPasswordAtSignUp = watch('signUp.password');
const setupVerificationAtSignUp = watch('signUp.verify');
if (
!signUpIdentifier ||
setupPasswordAtSignUp === undefined ||
setupVerificationAtSignUp === undefined
) {
return null;
}
return (
<>
<div className={styles.title}>{t('sign_in_exp.sign_up_and_sign_in.sign_in.title')}</div>
@ -35,25 +15,7 @@ const SignInForm = () => {
<div className={styles.formFieldDescription}>
{t('sign_in_exp.sign_up_and_sign_in.sign_in.description')}
</div>
<Controller
control={control}
name="signIn.methods"
defaultValue={[]}
render={({ field: { value, onChange } }) => {
return (
<SignInMethodEditBox
value={value}
requiredSignInIdentifiers={signUpToSignInIdentifierMapping[signUpIdentifier]}
ignoredWarningConnectors={
signUpIdentifierToRequiredConnectorMapping[signUpIdentifier]
}
isSignUpPasswordRequired={setupPasswordAtSignUp}
isSignUpVerificationRequired={setupVerificationAtSignUp}
onChange={onChange}
/>
);
}}
/>
<SignInMethodEditBox />
</FormField>
</>
);

View file

@ -10,10 +10,15 @@ import useEnabledConnectorTypes from '@/hooks/use-enabled-connector-types';
import type { SignInExperienceForm } from '../../types';
import ConnectorSetupWarning from './components/ConnectorSetupWarning';
import {
getSignInMethodPasswordCheckState,
getSignInMethodVerificationCodeCheckState,
} from './components/SignInMethodEditBox/utilities';
import {
requiredVerifySignUpIdentifiers,
signUpIdentifiers,
signUpIdentifierToRequiredConnectorMapping,
signUpToSignInIdentifierMapping,
} from './constants';
import * as styles from './index.module.scss';
@ -22,12 +27,14 @@ const SignUpForm = () => {
const {
control,
setValue,
getValues,
watch,
formState: { errors },
trigger,
formState: { errors, submitCount },
} = useFormContext<SignInExperienceForm>();
const { isConnectorTypeEnabled } = useEnabledConnectorTypes();
const signUpIdentifier = watch('signUp.identifier');
const { identifier: signUpIdentifier } = watch('signUp') ?? {};
if (!signUpIdentifier) {
return null;
@ -53,6 +60,53 @@ const SignUpForm = () => {
}
};
const refreshSignInMethods = () => {
const signUpIdentifier = getValues('signUp.identifier');
const signInMethods = getValues('signIn.methods');
const isSignUpPasswordRequired = getValues('signUp.password');
// Note: append required sign-in methods according to the sign-up identifier config
const requiredSignInIdentifiers = signUpToSignInIdentifierMapping[signUpIdentifier];
const allSignInMethods = requiredSignInIdentifiers.reduce((methods, requiredIdentifier) => {
if (signInMethods.some(({ identifier }) => identifier === requiredIdentifier)) {
return methods;
}
return [
...methods,
{
identifier: requiredIdentifier,
password: getSignInMethodPasswordCheckState(requiredIdentifier, isSignUpPasswordRequired),
verificationCode: getSignInMethodVerificationCodeCheckState(requiredIdentifier),
isPasswordPrimary: true,
},
];
}, signInMethods);
setValue(
'signIn.methods',
// Note: refresh sign-in authentications according to the sign-up authentications config
allSignInMethods.map((method) => {
const { identifier, password } = method;
return {
...method,
password: getSignInMethodPasswordCheckState(
identifier,
isSignUpPasswordRequired,
password
),
verificationCode: getSignInMethodVerificationCodeCheckState(identifier),
};
})
);
// Note: we need to revalidate the sign-in methods after we have submitted
if (submitCount) {
void trigger('signIn.methods');
}
};
return (
<>
<div className={styles.title}>{t('sign_in_exp.sign_up_and_sign_in.sign_up.title')}</div>
@ -65,10 +119,6 @@ const SignUpForm = () => {
control={control}
rules={{
validate: (value) => {
if (!value) {
return false;
}
return signUpIdentifierToRequiredConnectorMapping[value].every((connectorType) =>
isConnectorTypeEnabled(connectorType)
);
@ -101,6 +151,7 @@ const SignUpForm = () => {
}
onChange(value);
postSignUpIdentifierChange(value);
refreshSignInMethods();
}}
/>
)}
@ -122,9 +173,12 @@ const SignUpForm = () => {
<Checkbox
label={t('sign_in_exp.sign_up_and_sign_in.sign_up.set_a_password_option')}
disabled={signUpIdentifier === SignUpIdentifier.Username}
value={value ?? false}
value={value}
disabledTooltip={t('sign_in_exp.sign_up_and_sign_in.tip.set_a_password')}
onChange={onChange}
onChange={(value) => {
onChange(value);
refreshSignInMethods();
}}
/>
)}
/>
@ -135,10 +189,13 @@ const SignUpForm = () => {
render={({ field: { value, onChange } }) => (
<Checkbox
label={t('sign_in_exp.sign_up_and_sign_in.sign_up.verify_at_sign_up_option')}
value={value ?? false}
value={value}
disabled={requiredVerifySignUpIdentifiers.includes(signUpIdentifier)}
disabledTooltip={t('sign_in_exp.sign_up_and_sign_in.tip.verify_at_sign_up')}
onChange={onChange}
onChange={(value) => {
onChange(value);
refreshSignInMethods();
}}
/>
)}
/>

View file

@ -1,3 +1,4 @@
import type { ConnectorType } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
@ -10,6 +11,7 @@ import SwitchArrowIcon from '@/assets/images/switch-arrow.svg';
import Checkbox from '@/components/Checkbox';
import IconButton from '@/components/IconButton';
import ConnectorSetupWarning from '../ConnectorSetupWarning';
import * as styles from './index.module.scss';
import type { SignInMethod } from './types';
@ -18,13 +20,15 @@ type Props = {
isPasswordCheckable: boolean;
isVerificationCodeCheckable: boolean;
isDeletable: boolean;
requiredConnectors: ConnectorType[];
hasError?: boolean;
errorMessage?: string;
onVerificationStateChange: (
identifier: SignInIdentifier,
verification: 'password' | 'verificationCode',
checked: boolean
) => void;
onToggleVerificationPrimary: (identifier: SignInIdentifier) => void;
onDelete: (identifier: SignInIdentifier) => void;
onToggleVerificationPrimary: () => void;
onDelete: () => void;
};
const SignInMethodItem = ({
@ -32,6 +36,9 @@ const SignInMethodItem = ({
isPasswordCheckable,
isVerificationCodeCheckable,
isDeletable,
requiredConnectors,
hasError,
errorMessage,
onVerificationStateChange,
onToggleVerificationPrimary,
onDelete,
@ -39,71 +46,71 @@ const SignInMethodItem = ({
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div key={snakeCase(identifier)} className={styles.signInMethodItem}>
<div className={styles.signInMethod}>
<div className={styles.identifier}>
<Draggable className={styles.draggableIcon} />
{t('sign_in_exp.sign_up_and_sign_in.identifiers', {
context: snakeCase(identifier),
})}
<div>
<div key={snakeCase(identifier)} className={styles.signInMethodItem}>
<div className={classNames(styles.signInMethod, hasError && styles.error)}>
<div className={styles.identifier}>
<Draggable className={styles.draggableIcon} />
{t('sign_in_exp.sign_up_and_sign_in.identifiers', {
context: snakeCase(identifier),
})}
</div>
<div
className={classNames(
styles.authentication,
!isPasswordPrimary && styles.verifyCodePrimary
)}
>
<Checkbox
className={styles.checkBox}
label={t('sign_in_exp.sign_up_and_sign_in.sign_in.password_auth')}
value={password}
disabled={!isPasswordCheckable}
disabledTooltip={t('sign_in_exp.sign_up_and_sign_in.tip.password_auth')}
onChange={(checked) => {
onVerificationStateChange('password', checked);
}}
/>
{identifier !== SignInIdentifier.Username && (
<>
<IconButton
className={styles.swapButton}
tooltip={t('sign_in_exp.sign_up_and_sign_in.sign_in.auth_swap_tip')}
onClick={onToggleVerificationPrimary}
>
<SwitchArrowIcon />
</IconButton>
<Checkbox
className={styles.checkBox}
label={t('sign_in_exp.sign_up_and_sign_in.sign_in.verification_code_auth')}
value={verificationCode}
disabled={!isVerificationCodeCheckable}
disabledTooltip={t('sign_in_exp.sign_up_and_sign_in.tip.verification_code_auth')}
onChange={(checked) => {
onVerificationStateChange('verificationCode', checked);
}}
/>
</>
)}
</div>
</div>
<div
className={classNames(
styles.authentication,
!isPasswordPrimary && styles.verifyCodePrimary
<IconButton
disabled={!isDeletable}
tooltip={conditional(
!isDeletable &&
t('sign_in_exp.sign_up_and_sign_in.tip.delete_sign_in_method', {
identifier: t('sign_in_exp.sign_up_and_sign_in.identifiers', {
context: snakeCase(identifier),
}).toLocaleLowerCase(),
})
)}
onClick={onDelete}
>
<Checkbox
className={styles.checkBox}
label={t('sign_in_exp.sign_up_and_sign_in.sign_in.password_auth')}
value={password}
disabled={!isPasswordCheckable}
disabledTooltip={t('sign_in_exp.sign_up_and_sign_in.tip.password_auth')}
onChange={(checked) => {
onVerificationStateChange(identifier, 'password', checked);
}}
/>
{identifier !== SignInIdentifier.Username && (
<>
<IconButton
className={styles.swapButton}
tooltip={t('sign_in_exp.sign_up_and_sign_in.sign_in.auth_swap_tip')}
onClick={() => {
onToggleVerificationPrimary(identifier);
}}
>
<SwitchArrowIcon />
</IconButton>
<Checkbox
className={styles.checkBox}
label={t('sign_in_exp.sign_up_and_sign_in.sign_in.verification_code_auth')}
value={verificationCode}
disabled={!isVerificationCodeCheckable}
disabledTooltip={t('sign_in_exp.sign_up_and_sign_in.tip.verification_code_auth')}
onChange={(checked) => {
onVerificationStateChange(identifier, 'verificationCode', checked);
}}
/>
</>
)}
</div>
<Minus />
</IconButton>
</div>
<IconButton
disabled={!isDeletable}
tooltip={conditional(
!isDeletable &&
t('sign_in_exp.sign_up_and_sign_in.tip.delete_sign_in_method', {
identifier: t('sign_in_exp.sign_up_and_sign_in.identifiers', {
context: snakeCase(identifier),
}).toLocaleLowerCase(),
})
)}
onClick={() => {
onDelete(identifier);
}}
>
<Minus />
</IconButton>
{errorMessage && <div className={styles.errorMessage}>{errorMessage}</div>}
<ConnectorSetupWarning requiredConnectors={requiredConnectors} />
</div>
);
};

View file

@ -22,6 +22,10 @@
cursor: move;
color: var(--color-text);
&.error {
outline: 1px solid var(--color-error);
}
.identifier {
width: 130px;
display: flex;
@ -67,3 +71,8 @@
.addSignInMethodDropDown {
min-width: unset;
}
.errorMessage {
font: var(--font-body-medium);
color: var(--color-error);
}

View file

@ -1,178 +1,153 @@
import type { ConnectorType } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import { useCallback, useEffect, useRef } from 'react';
import { conditional } from '@silverhand/essentials';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import DragDropProvider from '@/components/Transfer/DragDropProvider';
import DraggableItem from '@/components/Transfer/DraggableItem';
import useEnabledConnectorTypes from '@/hooks/use-enabled-connector-types';
import type { SignInExperienceForm } from '@/pages/SignInExperience/types';
import { signInIdentifiers, signInIdentifierToRequiredConnectorMapping } from '../../constants';
import ConnectorSetupWarning from '../ConnectorSetupWarning';
import {
signInIdentifiers,
signInIdentifierToRequiredConnectorMapping,
signUpIdentifierToRequiredConnectorMapping,
signUpToSignInIdentifierMapping,
} from '../../constants';
import AddButton from './AddButton';
import SignInMethodItem from './SignInMethodItem';
import * as styles from './index.module.scss';
import type { SignInMethod } from './types';
import {
computeOnSignInMethodAppended,
computeOnVerificationStateChanged,
computeOnPasswordPrimaryFlagToggled,
getSignInMethodPasswordCheckState,
getSignInMethodVerificationCodeCheckState,
} from './utilities';
type Props = {
value: SignInMethod[];
onChange: (value: SignInMethod[]) => void;
requiredSignInIdentifiers: SignInIdentifier[];
ignoredWarningConnectors: ConnectorType[];
isSignUpPasswordRequired: boolean;
isSignUpVerificationRequired: boolean;
};
const SignInMethodEditBox = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
control,
watch,
trigger,
formState: { submitCount },
} = useFormContext<SignInExperienceForm>();
const signUp = watch('signUp');
const SignInMethodEditBox = ({
value,
onChange,
requiredSignInIdentifiers,
ignoredWarningConnectors,
isSignUpPasswordRequired,
isSignUpVerificationRequired,
}: Props) => {
const signInIdentifierOptions = signInIdentifiers.filter((candidateIdentifier) =>
value.every(({ identifier }) => identifier !== candidateIdentifier)
);
const { fields, swap, update, remove, append } = useFieldArray({
control,
name: 'signIn.methods',
});
// Note: add a reference to avoid infinite loop when change the value by `useEffect`
const signInMethods = useRef(value);
const handleChange = useCallback(
(value: SignInMethod[]) => {
// eslint-disable-next-line @silverhand/fp/no-mutation
signInMethods.current = value;
onChange(value);
},
[onChange]
);
const addSignInMethod = useCallback(
(identifier: SignInIdentifier) => {
handleChange(
computeOnSignInMethodAppended(value, {
identifier,
password: getSignInMethodPasswordCheckState(identifier, isSignUpPasswordRequired),
verificationCode: getSignInMethodVerificationCodeCheckState(identifier),
isPasswordPrimary: true,
})
);
},
[handleChange, value, isSignUpPasswordRequired]
);
useEffect(() => {
const allSignInMethods = requiredSignInIdentifiers.reduce(
(previous, current) =>
computeOnSignInMethodAppended(previous, {
identifier: current,
password: getSignInMethodPasswordCheckState(current, isSignUpPasswordRequired),
verificationCode: getSignInMethodVerificationCodeCheckState(current),
isPasswordPrimary: true,
}),
signInMethods.current
);
handleChange(
allSignInMethods.map((method) => ({
...method,
password: getSignInMethodPasswordCheckState(
method.identifier,
isSignUpPasswordRequired,
method.password
),
verificationCode: getSignInMethodVerificationCodeCheckState(method.identifier),
}))
);
}, [
handleChange,
isSignUpPasswordRequired,
isSignUpVerificationRequired,
requiredSignInIdentifiers,
]);
const onMoveItem = (dragIndex: number, hoverIndex: number) => {
const dragItem = value[dragIndex];
const hoverItem = value[hoverIndex];
if (!dragItem || !hoverItem) {
return;
const revalidate = () => {
if (submitCount) {
void trigger(`signIn.methods`);
}
handleChange(
value.map((value_, index) => {
if (index === dragIndex) {
return hoverItem;
}
if (index === hoverIndex) {
return dragItem;
}
return value_;
})
);
};
const { isConnectorTypeEnabled } = useEnabledConnectorTypes();
if (!signUp) {
return null;
}
const {
identifier: signUpIdentifier,
password: isSignUpPasswordRequired,
verify: isSignUpVerificationRequired,
} = signUp;
const requiredSignInIdentifiers = signUpToSignInIdentifierMapping[signUpIdentifier];
const ignoredWarningConnectors = signUpIdentifierToRequiredConnectorMapping[signUpIdentifier];
const signInIdentifierOptions = signInIdentifiers.filter((candidateIdentifier) =>
fields.every(({ identifier }) => identifier !== candidateIdentifier)
);
return (
<div>
<DragDropProvider>
{value.map((signInMethod, index) => (
<DraggableItem
key={signInMethod.identifier}
id={signInMethod.identifier}
sortIndex={index}
moveItem={onMoveItem}
className={styles.draggleItemContainer}
>
<SignInMethodItem
signInMethod={signInMethod}
isPasswordCheckable={
signInMethod.identifier !== SignInIdentifier.Username && !isSignUpPasswordRequired
}
isVerificationCodeCheckable={
!(isSignUpVerificationRequired && !isSignUpPasswordRequired)
}
isDeletable={!requiredSignInIdentifiers.includes(signInMethod.identifier)}
onVerificationStateChange={(identifier, verification, checked) => {
handleChange(
computeOnVerificationStateChanged(value, identifier, verification, checked)
);
}}
onToggleVerificationPrimary={(identifier) => {
handleChange(computeOnPasswordPrimaryFlagToggled(value, identifier));
}}
onDelete={(identifier) => {
handleChange(value.filter((method) => method.identifier !== identifier));
}}
/>
</DraggableItem>
))}
{fields.map((signInMethod, index) => {
const { id, identifier, verificationCode, isPasswordPrimary } = signInMethod;
const requiredConnectors =
conditional(
verificationCode &&
signInIdentifierToRequiredConnectorMapping[identifier].filter(
(connector) => !ignoredWarningConnectors.includes(connector)
)
) ?? [];
return (
<DraggableItem
key={id}
id={id}
sortIndex={index}
moveItem={swap}
className={styles.draggleItemContainer}
>
<Controller
control={control}
name={`signIn.methods.${index}`}
rules={{
validate: ({ password, verificationCode }) => {
if (!password && !verificationCode) {
return t('sign_in_exp.sign_up_and_sign_in.sign_in.require_auth_factor');
}
if (
verificationCode &&
requiredConnectors.some(
(connectorType) => !isConnectorTypeEnabled(connectorType)
)
) {
// Note: when required connectors are not all enabled, we show error state without error message for we have the connector setup warning
return false;
}
return true;
},
}}
render={({ field: { value }, fieldState: { error } }) => (
<SignInMethodItem
signInMethod={value}
isPasswordCheckable={
identifier !== SignInIdentifier.Username && !isSignUpPasswordRequired
}
isVerificationCodeCheckable={
!(isSignUpVerificationRequired && !isSignUpPasswordRequired)
}
isDeletable={!requiredSignInIdentifiers.includes(identifier)}
requiredConnectors={requiredConnectors}
hasError={Boolean(error)}
errorMessage={error?.message}
onVerificationStateChange={(verification, checked) => {
update(index, { ...value, [verification]: checked });
revalidate();
}}
onToggleVerificationPrimary={() => {
update(index, { ...value, isPasswordPrimary: !isPasswordPrimary });
revalidate();
}}
onDelete={() => {
remove(index);
}}
/>
)}
/>
</DraggableItem>
);
})}
</DragDropProvider>
<ConnectorSetupWarning
requiredConnectors={value
.reduce<ConnectorType[]>(
(connectors, { identifier: signInIdentifier, verificationCode }) => {
return [
...connectors,
...(verificationCode
? signInIdentifierToRequiredConnectorMapping[signInIdentifier]
: []),
];
},
[]
)
.filter((connector) => !ignoredWarningConnectors.includes(connector))}
/>
<AddButton
options={signInIdentifierOptions}
hasSelectedIdentifiers={value.length > 0}
onSelected={addSignInMethod}
hasSelectedIdentifiers={fields.length > 0}
onSelected={(identifier) => {
append({
identifier,
password: getSignInMethodPasswordCheckState(identifier, isSignUpPasswordRequired),
verificationCode: getSignInMethodVerificationCodeCheckState(identifier),
isPasswordPrimary: true,
});
}}
/>
</div>
);

View file

@ -1,48 +1,5 @@
import { SignInIdentifier } from '@logto/schemas';
import type { SignInMethod } from './types';
export const computeOnVerificationStateChanged = (
oldValue: SignInMethod[],
identifier: SignInIdentifier,
verification: 'password' | 'verificationCode',
checked: boolean
) =>
oldValue.map((method) =>
method.identifier === identifier
? {
...method,
[verification]: checked,
}
: method
);
export const computeOnSignInMethodAppended = (
appendTo: SignInMethod[],
appended: SignInMethod
): SignInMethod[] => {
const { identifier: signInIdentifier } = appended;
if (appendTo.some((method) => method.identifier === signInIdentifier)) {
return appendTo;
}
return [...appendTo, appended];
};
export const computeOnPasswordPrimaryFlagToggled = (
oldValue: SignInMethod[],
identifier: SignInIdentifier
) =>
oldValue.map((method) =>
method.identifier === identifier
? {
...method,
isPasswordPrimary: !method.isPasswordPrimary,
}
: method
);
export const getSignInMethodPasswordCheckState = (
signInIdentifier: SignInIdentifier,
isSignUpPasswordRequired: boolean,

View file

@ -1,6 +1,6 @@
import type { SignInExperience, SignUp } from '@logto/schemas';
export type SignInExperienceForm = Omit<SignInExperience, 'signInMethods' | 'signUp'> & {
signUp: Partial<SignUp>;
signUp?: SignUp;
createAccountEnabled: boolean;
};

View file

@ -30,10 +30,10 @@ export const signInExperienceParser = {
darkLogoUrl: conditional(branding.darkLogoUrl?.length && branding.darkLogoUrl),
slogan: conditional(branding.slogan?.length && branding.slogan),
},
signUp: {
identifier: signUp.identifier ?? SignUpIdentifier.Username,
password: Boolean(signUp.password),
verify: Boolean(signUp.verify),
signUp: signUp ?? {
identifier: SignUpIdentifier.Username,
password: true,
verify: false,
},
signInMode: createAccountEnabled ? SignInMode.SignInAndRegister : SignInMode.SignIn,
};

View file

@ -46,6 +46,7 @@ const sign_in_exp = {
password_auth: 'Password', // UNTRANSLATED
verification_code_auth: 'Verification code', // UNTRANSLATED
auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', // UNTRANSLATED
require_auth_factor: 'You have to select at least one authentication factor.', // UNTRANSLATED
},
social_sign_in: {
title: 'SOCIAL SIGN-IN', // UNTRANSLATED

View file

@ -68,6 +68,7 @@ const sign_in_exp = {
password_auth: 'Password',
verification_code_auth: 'Verification code',
auth_swap_tip: 'Swap the options below to determine which appears first in the flow.',
require_auth_factor: 'You have to select at least one authentication factor.',
},
social_sign_in: {
title: 'SOCIAL SIGN-IN',

View file

@ -70,6 +70,7 @@ const sign_in_exp = {
password_auth: 'Password', // UNTRANSLATED
verification_code_auth: 'Verification code', // UNTRANSLATED
auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', // UNTRANSLATED
require_auth_factor: 'You have to select at least one authentication factor.', // UNTRANSLATED
},
social_sign_in: {
title: 'SOCIAL SIGN-IN', // UNTRANSLATED

View file

@ -65,6 +65,7 @@ const sign_in_exp = {
password_auth: 'Password', // UNTRANSLATED
verification_code_auth: 'Verification code', // UNTRANSLATED
auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', // UNTRANSLATED
require_auth_factor: 'You have to select at least one authentication factor.', // UNTRANSLATED
},
social_sign_in: {
title: 'SOCIAL SIGN-IN', // UNTRANSLATED

View file

@ -68,6 +68,7 @@ const sign_in_exp = {
password_auth: 'Password', // UNTRANSLATED
verification_code_auth: 'Verification code', // UNTRANSLATED
auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', // UNTRANSLATED
require_auth_factor: 'You have to select at least one authentication factor.', // UNTRANSLATED
},
social_sign_in: {
title: 'SOCIAL SIGN-IN', // UNTRANSLATED

View file

@ -69,6 +69,7 @@ const sign_in_exp = {
password_auth: 'Password', // UNTRANSLATED
verification_code_auth: 'Verification code', // UNTRANSLATED
auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', // UNTRANSLATED
require_auth_factor: 'You have to select at least one authentication factor.', // UNTRANSLATED
},
social_sign_in: {
title: 'SOCIAL SIGN-IN', // UNTRANSLATED

View file

@ -63,6 +63,7 @@ const sign_in_exp = {
password_auth: '密码',
verification_code_auth: '验证码',
auth_swap_tip: '交换以下选项的位置即可设定它们在用户登录流程中出现的先后。',
require_auth_factor: 'You have to select at least one authentication factor.', // UNTRANSLATED
},
social_sign_in: {
title: '社交登录',