['VerifyUser']
>;
+export type DefaultSetupEmailProps = React.ComponentPropsWithoutRef<
+ DefaultComponents<
+ TextFieldOptionsType,
+ { style?: SetupEmailStyle }
+ >['SetupEmail']
+>;
+
+export type DefaultSelectMfaTypeProps = React.ComponentPropsWithoutRef<
+ DefaultComponents<
+ RadioFieldOptions,
+ { style?: SelectMfaTypeStyle }
+ >['SelectMfaType']
+>;
+
/**
* Custom Authenticator components
*/
@@ -143,6 +159,16 @@ type VerifyUserComponent = OverrideComponents<
{ style?: VerifyUserStyle } & P
>['VerifyUser'];
+type SetupEmailComponent
= OverrideComponents<
+ TextFieldOptionsType,
+ { style?: SetupEmailStyle } & P
+>['SetupEmail'];
+
+type SelectMfaTypeComponent
= OverrideComponents<
+ RadioFieldOptions,
+ { style?: SelectMfaTypeStyle } & P
+>['SelectMfaType'];
+
/**
* Override `Authenticator` components param
*/
@@ -157,4 +183,6 @@ export interface Components {
SignIn?: SignInComponent;
SignUp?: SignUpComponent;
VerifyUser?: VerifyUserComponent;
+ SetupEmail?: SetupEmailComponent;
+ SelectMfaType?: SelectMfaTypeComponent;
}
diff --git a/packages/react-native/src/Authenticator/common/DefaultFormFields/DefaultFormFields.tsx b/packages/react-native/src/Authenticator/common/DefaultFormFields/DefaultFormFields.tsx
new file mode 100644
index 00000000000..da159f02982
--- /dev/null
+++ b/packages/react-native/src/Authenticator/common/DefaultFormFields/DefaultFormFields.tsx
@@ -0,0 +1,83 @@
+import React, { Fragment } from 'react';
+import { getErrors } from '@aws-amplify/ui';
+
+import { Radio, RadioGroup } from '../../../primitives';
+import { DefaultFormFieldsProps } from './types';
+import { View } from 'react-native';
+import { isRadioFieldOptions } from '../../hooks/useFieldValues/utils';
+import Field from './Field';
+import { FieldErrors } from './FieldErrors';
+
+const DefaultFormFields = ({
+ fieldContainerStyle,
+ fieldErrorsContainer,
+ fieldErrorStyle,
+ fieldStyle,
+ fieldLabelStyle,
+ isPending = false,
+ validationErrors,
+ fields = [],
+ style,
+}: DefaultFormFieldsProps): React.JSX.Element => {
+ const formFields = fields.map((field) => {
+ const errors = validationErrors
+ ? getErrors(validationErrors?.[field.name])
+ : [];
+ const hasError = errors?.length > 0;
+
+ if (isRadioFieldOptions(field)) {
+ return (
+
+
+ {(field.radioOptions ?? []).map(({ label, value }) => {
+ return (
+
+ );
+ })}
+
+
+
+ );
+ }
+ return (
+
+
+
+
+ );
+ });
+
+ return {formFields};
+};
+
+DefaultFormFields.displayName = 'FormFields';
+
+export default DefaultFormFields;
diff --git a/packages/react-native/src/Authenticator/common/DefaultFormFields/DefaultRadioFormFields.tsx b/packages/react-native/src/Authenticator/common/DefaultFormFields/DefaultVerifyUserFormFields.tsx
similarity index 80%
rename from packages/react-native/src/Authenticator/common/DefaultFormFields/DefaultRadioFormFields.tsx
rename to packages/react-native/src/Authenticator/common/DefaultFormFields/DefaultVerifyUserFormFields.tsx
index c0473ef9c4a..3dcd87d351a 100644
--- a/packages/react-native/src/Authenticator/common/DefaultFormFields/DefaultRadioFormFields.tsx
+++ b/packages/react-native/src/Authenticator/common/DefaultFormFields/DefaultVerifyUserFormFields.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { censorContactMethod, ContactMethod } from '@aws-amplify/ui';
import { Radio, RadioGroup } from '../../../primitives';
-import { DefaultRadioFormFieldsProps } from './types';
+import { DefaultVerifyUserFormFieldsProps } from './types';
interface AttributeMap {
email: ContactMethod;
@@ -14,13 +14,13 @@ const attributeMap: AttributeMap = {
phone_number: 'Phone Number',
};
-const DefaultRadioFormFields = ({
+const DefaultVerifyUserFormFields = ({
fields,
fieldContainerStyle,
fieldLabelStyle,
isPending,
style,
-}: DefaultRadioFormFieldsProps): React.JSX.Element => {
+}: DefaultVerifyUserFormFieldsProps): React.JSX.Element => {
return (
{(fields ?? []).map(({ name, value, ...props }) => {
@@ -43,6 +43,6 @@ const DefaultRadioFormFields = ({
);
};
-DefaultRadioFormFields.displayName = 'FormFields';
+DefaultVerifyUserFormFields.displayName = 'FormFields';
-export default DefaultRadioFormFields;
+export default DefaultVerifyUserFormFields;
diff --git a/packages/react-native/src/Authenticator/common/DefaultFormFields/index.ts b/packages/react-native/src/Authenticator/common/DefaultFormFields/index.ts
index baaf4c190fc..2dbb4e259a8 100644
--- a/packages/react-native/src/Authenticator/common/DefaultFormFields/index.ts
+++ b/packages/react-native/src/Authenticator/common/DefaultFormFields/index.ts
@@ -1,3 +1,4 @@
-export { default as DefaultRadioFormFields } from './DefaultRadioFormFields';
export { default as DefaultTextFormFields } from './DefaultTextFormFields';
+export { default as DefaultVerifyUserFormFields } from './DefaultVerifyUserFormFields';
+export { default as DefaultFormFields } from './DefaultFormFields';
export { DefaultFormFieldsComponent, DefaultFormFieldsStyle } from './types';
diff --git a/packages/react-native/src/Authenticator/common/DefaultFormFields/types.ts b/packages/react-native/src/Authenticator/common/DefaultFormFields/types.ts
index 4a506d5ac82..a5723ed735c 100644
--- a/packages/react-native/src/Authenticator/common/DefaultFormFields/types.ts
+++ b/packages/react-native/src/Authenticator/common/DefaultFormFields/types.ts
@@ -4,7 +4,11 @@ import {
UseAuthenticator,
} from '@aws-amplify/ui-react-core';
-import { RadioFieldOptions, TextFieldOptionsType } from '../../hooks';
+import {
+ RadioFieldOptions,
+ TextFieldOptionsType,
+ TypedField,
+} from '../../hooks';
export type FieldProps = Omit & {
disabled: boolean;
@@ -33,10 +37,14 @@ interface FormFieldsProps extends DefaultFormFieldsStyle {
validationErrors?: UseAuthenticator['validationErrors'];
}
+export interface DefaultFormFieldsProps extends FormFieldsProps {
+ fields?: TypedField[];
+}
+
export interface DefaultTextFormFieldsProps extends FormFieldsProps {
fields?: TextFieldOptionsType[];
}
-export interface DefaultRadioFormFieldsProps extends FormFieldsProps {
+export interface DefaultVerifyUserFormFieldsProps extends FormFieldsProps {
fields?: RadioFieldOptions[];
}
diff --git a/packages/react-native/src/Authenticator/hooks/types.ts b/packages/react-native/src/Authenticator/hooks/types.ts
index e71a227581b..9c1e444a45a 100644
--- a/packages/react-native/src/Authenticator/hooks/types.ts
+++ b/packages/react-native/src/Authenticator/hooks/types.ts
@@ -35,7 +35,9 @@ export type TextFieldOptionsType = (
| DefaultFieldOptions
) & { labelHidden?: boolean };
-export type RadioFieldOptions = FieldOptions, 'radio'>;
+export type RadioFieldOptions = FieldOptions, 'radio'> & {
+ radioOptions?: { label: string; value: string }[];
+};
/**
* `field` options union
diff --git a/packages/react-native/src/Authenticator/hooks/useFieldValues/__tests__/utils.spec.ts b/packages/react-native/src/Authenticator/hooks/useFieldValues/__tests__/utils.spec.ts
index 09030d04b2b..312e2d38a24 100644
--- a/packages/react-native/src/Authenticator/hooks/useFieldValues/__tests__/utils.spec.ts
+++ b/packages/react-native/src/Authenticator/hooks/useFieldValues/__tests__/utils.spec.ts
@@ -4,9 +4,10 @@ import { authenticatorTextUtil } from '@aws-amplify/ui';
import { TextFieldOptionsType, TypedField } from '../../types';
import {
getRouteTypedFields,
- getSanitizedRadioFields,
+ getSanitizedVerifyUserFields,
getSanitizedTextFields,
runFieldValidation,
+ getSanitizedFields,
} from '../utils';
const warnSpy = jest.spyOn(Logger.prototype, 'warn');
@@ -117,7 +118,7 @@ describe('getSanitizedRadioFields', () => {
{ type: 'password', value: 'value' } as TypedField,
];
- const output = getSanitizedRadioFields(fields, 'VerifyUser');
+ const output = getSanitizedVerifyUserFields(fields);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
@@ -139,7 +140,7 @@ describe('getSanitizedRadioFields', () => {
{ name: 'phone_number', type: 'radio' } as TypedField,
];
- const output = getSanitizedRadioFields(fields, 'VerifyUser');
+ const output = getSanitizedVerifyUserFields(fields);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
@@ -161,7 +162,7 @@ describe('getSanitizedRadioFields', () => {
{ name: 'email', type: 'radio', value: 'testValue' } as TypedField,
];
- const output = getSanitizedRadioFields(fields, 'VerifyUser');
+ const output = getSanitizedVerifyUserFields(fields);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
@@ -173,6 +174,74 @@ describe('getSanitizedRadioFields', () => {
});
});
+describe('getSanitizedFields', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('logs a warning and ignores the field when name is missing', () => {
+ const validField: TypedField = {
+ name: 'email',
+ type: 'radio',
+ value: 'test',
+ radioOptions: [{ label: 'test', value: 'test' }],
+ };
+ const fields: TypedField[] = [
+ validField,
+ { type: 'password', value: 'value' } as TypedField,
+ ];
+
+ const output = getSanitizedFields(fields);
+
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(warnSpy).toHaveBeenCalledWith(
+ 'Each field must have a name; field has been ignored.'
+ );
+
+ expect(output).toStrictEqual([validField]);
+ });
+
+ it('logs a warning and ignores the field when name is duplicated.', () => {
+ const validField: TypedField = {
+ name: 'email',
+ type: 'email',
+ value: 'value',
+ };
+ const fields: TypedField[] = [validField, { ...validField }];
+
+ const output = getSanitizedFields(fields);
+
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(warnSpy).toHaveBeenCalledWith(
+ 'Each field name must be unique; field with duplicate name of "email" has been ignored.'
+ );
+
+ expect(output).toStrictEqual([validField]);
+ });
+
+ it('logs a warning and ignores the field when radio input is present with no options.', () => {
+ const validField: TypedField = {
+ name: 'email',
+ type: 'radio',
+ value: 'testValue',
+ radioOptions: [{ label: 'test', value: 'test' }],
+ };
+ const fields: TypedField[] = [
+ validField,
+ { name: 'mfa_type', type: 'radio', value: 'testValue' } as TypedField,
+ ];
+
+ const output = getSanitizedFields(fields);
+
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(warnSpy).toHaveBeenCalledWith(
+ 'Each radio field must have at least one option available for selection; field of name "mfa_type" without radioOptions has been ignored.'
+ );
+
+ expect(output).toStrictEqual([validField]);
+ });
+});
+
describe('getRouteTypedFields', () => {
it('returns the expected value for a non-component route', () => {
const fields = getRouteTypedFields({ fields: [], route: idle });
diff --git a/packages/react-native/src/Authenticator/hooks/useFieldValues/constants.ts b/packages/react-native/src/Authenticator/hooks/useFieldValues/constants.ts
index 597d52cabfb..50cd577e293 100644
--- a/packages/react-native/src/Authenticator/hooks/useFieldValues/constants.ts
+++ b/packages/react-native/src/Authenticator/hooks/useFieldValues/constants.ts
@@ -5,4 +5,5 @@ export const KEY_ALLOW_LIST = [
'required',
'isRequired',
'type',
+ 'radioOptions',
];
diff --git a/packages/react-native/src/Authenticator/hooks/useFieldValues/useFieldValues.ts b/packages/react-native/src/Authenticator/hooks/useFieldValues/useFieldValues.ts
index 69250877faa..fdd49232197 100644
--- a/packages/react-native/src/Authenticator/hooks/useFieldValues/useFieldValues.ts
+++ b/packages/react-native/src/Authenticator/hooks/useFieldValues/useFieldValues.ts
@@ -7,9 +7,10 @@ import { OnChangeText, TextFieldOnBlur, TypedField } from '../types';
import { UseFieldValues, UseFieldValuesParams } from './types';
import {
getSanitizedTextFields,
- getSanitizedRadioFields,
isRadioFieldOptions,
runFieldValidation,
+ getSanitizedFields,
+ getSanitizedVerifyUserFields,
} from './utils';
const logger = new Logger('Authenticator');
@@ -26,7 +27,8 @@ export default function useFieldValues({
const [touched, setTouched] = useState>({});
const [fieldValidationErrors, setFieldValidationErrors] =
useState({});
- const isRadioFieldComponent = componentName === 'VerifyUser';
+ const isVerifyUserRoute = componentName === 'VerifyUser';
+ const isSelectMfaTypeRoute = componentName === 'SelectMfaType';
const sanitizedFields = useMemo(() => {
if (!Array.isArray(fields)) {
@@ -36,12 +38,15 @@ export default function useFieldValues({
return [];
}
- if (isRadioFieldComponent) {
- return getSanitizedRadioFields(fields, componentName);
+ if (isVerifyUserRoute) {
+ return getSanitizedVerifyUserFields(fields);
+ }
+ if (isSelectMfaTypeRoute) {
+ return getSanitizedFields(fields);
}
return getSanitizedTextFields(fields, componentName);
- }, [componentName, fields, isRadioFieldComponent]);
+ }, [componentName, fields, isVerifyUserRoute, isSelectMfaTypeRoute]);
const fieldsWithHandlers = sanitizedFields.map((field) => {
if (isRadioFieldOptions(field)) {
@@ -49,11 +54,16 @@ export default function useFieldValues({
// call `onChange` passed as radio `field` option
field.onChange?.(value);
- // set `name` as value of 'unverifiedAttr'
- setValues({ unverifiedAttr: value });
+ // on VerifyUser route, set `name` as value of 'unverifiedAttr'
+ const fieldName = isVerifyUserRoute ? 'unverifiedAttr' : field.name;
+ setValues((prev) => ({ ...prev, [fieldName]: value }));
};
- return { ...field, onChange };
+ return {
+ ...field,
+ onChange,
+ ...(isSelectMfaTypeRoute && { value: values[field.name] }),
+ };
}
const { name, label, labelHidden, ...rest } = field;
@@ -100,21 +110,12 @@ export default function useFieldValues({
};
}) as FieldType[];
- const disableFormSubmit = isRadioFieldComponent
+ const disableFormSubmit = isVerifyUserRoute
? !values.unverifiedAttr
- : fieldsWithHandlers.some(({ required, value }) => {
- if (!required) {
- return false;
- }
-
- if (value) {
- return false;
- }
- return true;
- });
+ : fieldsWithHandlers.some(({ required, value }) => required && !value);
const handleFormSubmit = () => {
- const submitValue = isRadioFieldComponent
+ const submitValue = isVerifyUserRoute
? values
: fieldsWithHandlers.reduce((acc, { name, value = '', type }) => {
/*
diff --git a/packages/react-native/src/Authenticator/hooks/useFieldValues/utils.ts b/packages/react-native/src/Authenticator/hooks/useFieldValues/utils.ts
index 0c8eb28d7a2..524f6304a78 100644
--- a/packages/react-native/src/Authenticator/hooks/useFieldValues/utils.ts
+++ b/packages/react-native/src/Authenticator/hooks/useFieldValues/utils.ts
@@ -18,7 +18,6 @@ import {
AuthenticatorFieldTypeKey,
MachineFieldTypeKey,
RadioFieldOptions,
- TextFieldOptionsType,
TypedField,
} from '../types';
import { KEY_ALLOW_LIST } from './constants';
@@ -31,16 +30,15 @@ export const isRadioFieldOptions = (
field: TypedField
): field is RadioFieldOptions => field?.type === 'radio';
-export const getSanitizedRadioFields = (
- fields: TypedField[],
- componentName: AuthenticatorRouteComponentName
+export const getSanitizedVerifyUserFields = (
+ fields: TypedField[]
): TypedField[] => {
const values: Record = {};
return fields.filter((field) => {
if (!isRadioFieldOptions(field)) {
logger.warn(
- `${componentName} component does not support text fields. field with type ${field.type} has been ignored.`
+ `VerifyUser component does not support text fields. field with type ${field.type} has been ignored.`
);
return false;
}
@@ -77,6 +75,36 @@ export const getSanitizedRadioFields = (
});
};
+export const getSanitizedFields = (fields: TypedField[]): TypedField[] => {
+ const names: Record = {};
+ return fields.filter((field) => {
+ const { name } = field;
+
+ if (!name) {
+ logger.warn('Each field must have a name; field has been ignored.');
+ return false;
+ }
+
+ if (names[name]) {
+ logger.warn(
+ `Each field name must be unique; field with duplicate name of "${name}" has been ignored.`
+ );
+ return false;
+ }
+
+ if (isRadioFieldOptions(field) && !field.radioOptions?.length) {
+ logger.warn(
+ `Each radio field must have at least one option available for selection; field of name "${name}" without radioOptions has been ignored.`
+ );
+ return false;
+ }
+
+ names[name] = true;
+
+ return true;
+ });
+};
+
export const getSanitizedTextFields = (
fields: TypedField[],
componentName: AuthenticatorRouteComponentName
@@ -117,7 +145,7 @@ const isKeyAllowed = (key: string) =>
const isValidMachineFieldType = (
type: string | undefined
): type is MachineFieldTypeKey =>
- type === 'password' || type === 'tel' || type == 'email';
+ type === 'password' || type === 'tel' || type == 'email' || type === 'radio';
const getFieldType = (type: string | undefined): AuthenticatorFieldTypeKey => {
if (isValidMachineFieldType(type)) {
@@ -193,7 +221,7 @@ export function getRouteTypedFields({
* @returns {string[]} field errors array
*/
export const runFieldValidation = (
- field: TextFieldOptionsType,
+ field: TypedField,
value: string | undefined,
stateValidations: ValidationError | undefined
): string[] => {
diff --git a/packages/react-native/src/primitives/RadioGroup/RadioGroup.tsx b/packages/react-native/src/primitives/RadioGroup/RadioGroup.tsx
index 6ca9c9065de..443c20a8295 100644
--- a/packages/react-native/src/primitives/RadioGroup/RadioGroup.tsx
+++ b/packages/react-native/src/primitives/RadioGroup/RadioGroup.tsx
@@ -19,6 +19,8 @@ import { RadioProps } from '../Radio';
import { getThemedStyles } from './styles';
import { RadioGroupProps } from './types';
+export const RADIO_GROUP_CONTAINER_TEST_ID = 'amplify__radio-group__container';
+
export default function RadioGroup({
accessible = true,
accessibilityRole = 'radiogroup',
@@ -73,7 +75,11 @@ export default function RadioGroup({
);
return (
-
+
@@ -46,6 +48,10 @@ const getRouteComponent = (route: string): RouteComponent => {
return VerifyUser;
case 'confirmVerifyUser':
return ConfirmVerifyUser;
+ case 'selectMfaType':
+ return SelectMfaType;
+ case 'setupEmail':
+ return SetupEmail;
default:
// eslint-disable-next-line no-console
console.warn(
diff --git a/packages/react/src/components/Authenticator/SelectMfaType/SelectMfaType.tsx b/packages/react/src/components/Authenticator/SelectMfaType/SelectMfaType.tsx
new file mode 100644
index 00000000000..ab9bce26f1f
--- /dev/null
+++ b/packages/react/src/components/Authenticator/SelectMfaType/SelectMfaType.tsx
@@ -0,0 +1,76 @@
+import * as React from 'react';
+
+import { Flex } from '../../../primitives/Flex';
+import { Heading } from '../../../primitives/Heading';
+import { useAuthenticator } from '@aws-amplify/ui-react-core';
+import { useCustomComponents } from '../hooks/useCustomComponents';
+import { useFormHandlers } from '../hooks/useFormHandlers';
+import { ConfirmSignInFooter } from '../shared/ConfirmSignInFooter';
+import { RemoteErrorMessage } from '../shared/RemoteErrorMessage';
+import { FormFields } from '../shared/FormFields';
+import { RouteContainer, RouteProps } from '../RouteContainer';
+import { authenticatorTextUtil } from '@aws-amplify/ui';
+
+const { getSelectMfaTypeByChallengeName } = authenticatorTextUtil;
+
+export const SelectMfaType = ({
+ className,
+ variation,
+}: RouteProps): JSX.Element => {
+ const { isPending } = useAuthenticator((context) => {
+ return [context.isPending];
+ });
+
+ const { handleChange, handleSubmit } = useFormHandlers();
+
+ const {
+ components: {
+ // @ts-ignore
+ SelectMfaType: {
+ Header = SelectMfaType.Header,
+ Footer = SelectMfaType.Footer,
+ },
+ },
+ } = useCustomComponents();
+
+ return (
+
+
+
+ );
+};
+
+SelectMfaType.Header = function Header(): JSX.Element {
+ const { challengeName } = useAuthenticator((context) => {
+ return [context.challengeName];
+ });
+
+ return (
+
+ {getSelectMfaTypeByChallengeName(challengeName)}
+
+ );
+};
+
+SelectMfaType.Footer = function Footer(): JSX.Element {
+ // @ts-ignore
+ return null;
+};
diff --git a/packages/react/src/components/Authenticator/SelectMfaType/__tests__/SelectMfaType.test.tsx b/packages/react/src/components/Authenticator/SelectMfaType/__tests__/SelectMfaType.test.tsx
new file mode 100644
index 00000000000..394e31eaefb
--- /dev/null
+++ b/packages/react/src/components/Authenticator/SelectMfaType/__tests__/SelectMfaType.test.tsx
@@ -0,0 +1,144 @@
+import * as React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+
+import { useAuthenticator, UseAuthenticator } from '@aws-amplify/ui-react-core';
+import { SelectMfaType } from '../SelectMfaType';
+import { AuthenticatorServiceFacade } from '@aws-amplify/ui';
+
+jest.mock('@aws-amplify/ui-react-core');
+
+jest.mock('../../hooks/useCustomComponents', () => ({
+ useCustomComponents: () => ({
+ components: {
+ Header: () => null,
+ Footer: () => null,
+ SelectMfaType: { Header: () => null, Footer: () => null },
+ },
+ }),
+}));
+
+const fieldLabel = 'Select MFA Type';
+const fieldInput = { name: 'mfa_type', value: 'EMAIL' };
+
+const mockUpdateForm = jest.fn();
+const mockSubmitForm = jest.fn();
+const mockToSignIn = jest.fn();
+
+const mockUseAuthenticator = jest.mocked(useAuthenticator);
+
+const mockUseAuthenticatorOutput: Partial = {
+ authStatus: 'authenticated',
+ challengeName: 'SELECT_MFA_TYPE',
+ error: undefined as unknown as AuthenticatorServiceFacade['error'],
+ route: 'selectMfaType',
+ submitForm: mockSubmitForm,
+ toSignIn: mockToSignIn,
+ updateForm: mockUpdateForm,
+ allowedMfaTypes: ['EMAIL', 'TOTP'],
+ validationErrors: {} as AuthenticatorServiceFacade['validationErrors'],
+ fields: [
+ {
+ name: 'mfa_type',
+ label: fieldLabel,
+ required: true,
+ type: 'radio',
+ radioOptions: [
+ {
+ label: 'EMAIL',
+ value: 'EMAIL',
+ },
+ {
+ label: 'TOTP',
+ value: 'TOTP',
+ },
+ ],
+ },
+ ],
+};
+
+mockUseAuthenticator.mockReturnValue(mockUseAuthenticatorOutput as any);
+
+const props = {
+ className: '',
+ variation: 'default' as const,
+};
+
+describe('SelectMfaType', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders as expected ', () => {
+ const mathRandomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.1);
+
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+
+ mathRandomSpy.mockRestore();
+ });
+
+ it('sends change event on form input', async () => {
+ render();
+
+ const radioButton = await screen.findByText(fieldInput.value);
+
+ fireEvent.click(radioButton);
+
+ expect(mockUpdateForm).toHaveBeenCalledWith(fieldInput);
+ });
+
+ it('sends submit event on form submit', async () => {
+ render();
+
+ const radioButton = await screen.findByText(fieldInput.value);
+
+ fireEvent.click(radioButton);
+
+ expect(mockUseAuthenticatorOutput.updateForm).toHaveBeenCalledWith(
+ fieldInput
+ );
+
+ const submitButton = await screen.findByRole('button', { name: 'Confirm' });
+ fireEvent.click(submitButton);
+
+ expect(mockSubmitForm).toHaveBeenCalledTimes(1);
+ });
+
+ it('displays error if present', async () => {
+ mockUseAuthenticator.mockReturnValue({
+ ...mockUseAuthenticatorOutput,
+ error: 'mockError',
+ } as any);
+
+ render();
+
+ expect(await screen.findByText('mockError')).toBeInTheDocument();
+ });
+
+ it('handles back to sign in button as expected', async () => {
+ render();
+
+ const backToSignInButton = await screen.findByRole('button', {
+ name: 'Back to Sign In',
+ });
+
+ fireEvent.click(backToSignInButton);
+
+ expect(mockToSignIn).toHaveBeenCalledTimes(1);
+ });
+
+ it('disables the submit button if confirmation is pending', async () => {
+ mockUseAuthenticator.mockReturnValue({
+ ...mockUseAuthenticatorOutput,
+ isPending: true,
+ } as any);
+
+ render();
+
+ const submitButton = await screen.findByRole('button', {
+ name: 'Confirming',
+ });
+
+ expect(submitButton).toBeDisabled();
+ });
+});
diff --git a/packages/react/src/components/Authenticator/SelectMfaType/__tests__/__snapshots__/SelectMfaType.test.tsx.snap b/packages/react/src/components/Authenticator/SelectMfaType/__tests__/__snapshots__/SelectMfaType.test.tsx.snap
new file mode 100644
index 00000000000..be1983c0e87
--- /dev/null
+++ b/packages/react/src/components/Authenticator/SelectMfaType/__tests__/__snapshots__/SelectMfaType.test.tsx.snap
@@ -0,0 +1,116 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SelectMfaType renders as expected 1`] = `
+
+`;
diff --git a/packages/react/src/components/Authenticator/SelectMfaType/index.ts b/packages/react/src/components/Authenticator/SelectMfaType/index.ts
new file mode 100644
index 00000000000..e0020dc5388
--- /dev/null
+++ b/packages/react/src/components/Authenticator/SelectMfaType/index.ts
@@ -0,0 +1 @@
+export { SelectMfaType } from './SelectMfaType';
diff --git a/packages/react/src/components/Authenticator/SetupEmail/SetupEmail.tsx b/packages/react/src/components/Authenticator/SetupEmail/SetupEmail.tsx
new file mode 100644
index 00000000000..7ceffbac778
--- /dev/null
+++ b/packages/react/src/components/Authenticator/SetupEmail/SetupEmail.tsx
@@ -0,0 +1,64 @@
+import * as React from 'react';
+
+import { authenticatorTextUtil } from '@aws-amplify/ui';
+
+import { Flex } from '../../../primitives/Flex';
+import { Heading } from '../../../primitives/Heading';
+import { useAuthenticator } from '@aws-amplify/ui-react-core';
+import { useCustomComponents } from '../hooks/useCustomComponents';
+import { useFormHandlers } from '../hooks/useFormHandlers';
+import { ConfirmSignInFooter } from '../shared/ConfirmSignInFooter';
+import { RemoteErrorMessage } from '../shared/RemoteErrorMessage';
+import { FormFields } from '../shared/FormFields';
+import { RouteContainer, RouteProps } from '../RouteContainer';
+
+const { getSetupEmailText } = authenticatorTextUtil;
+
+export const SetupEmail = ({
+ className,
+ variation,
+}: RouteProps): JSX.Element => {
+ const { isPending } = useAuthenticator((context) => [context.isPending]);
+
+ const { handleChange, handleSubmit } = useFormHandlers();
+
+ const {
+ components: {
+ // @ts-ignore
+ SetupEmail: { Header = SetupEmail.Header, Footer = SetupEmail.Footer },
+ },
+ } = useCustomComponents();
+
+ return (
+
+
+
+ );
+};
+
+SetupEmail.Header = function Header(): JSX.Element {
+ return {getSetupEmailText()};
+};
+
+SetupEmail.Footer = function Footer(): JSX.Element {
+ // @ts-ignore
+ return null;
+};
diff --git a/packages/react/src/components/Authenticator/SetupEmail/__tests__/SetupEmail.test.tsx b/packages/react/src/components/Authenticator/SetupEmail/__tests__/SetupEmail.test.tsx
new file mode 100644
index 00000000000..e7555f258e6
--- /dev/null
+++ b/packages/react/src/components/Authenticator/SetupEmail/__tests__/SetupEmail.test.tsx
@@ -0,0 +1,132 @@
+import * as React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+
+import { useAuthenticator, UseAuthenticator } from '@aws-amplify/ui-react-core';
+import { SetupEmail } from '../SetupEmail';
+import { AuthenticatorServiceFacade } from '@aws-amplify/ui';
+
+jest.mock('@aws-amplify/ui-react-core');
+
+jest.mock('../../hooks/useCustomComponents', () => ({
+ useCustomComponents: () => ({
+ components: {
+ Header: () => null,
+ Footer: () => null,
+ SetupEmail: { Header: () => null, Footer: () => null },
+ },
+ }),
+}));
+
+const fieldLabel = 'Seteup Email';
+const fieldInput = { name: 'email', value: 'user@example.com' };
+
+const mockUpdateForm = jest.fn();
+const mockSubmitForm = jest.fn();
+const mockToSignIn = jest.fn();
+
+const mockUseAuthenticator = jest.mocked(useAuthenticator);
+
+const mockUseAuthenticatorOutput: Partial = {
+ authStatus: 'authenticated',
+ challengeName: 'MFA_SETUP',
+ error: undefined as unknown as AuthenticatorServiceFacade['error'],
+ route: 'setupEmail',
+ submitForm: mockSubmitForm,
+ toSignIn: mockToSignIn,
+ updateForm: mockUpdateForm,
+ validationErrors: {} as AuthenticatorServiceFacade['validationErrors'],
+ fields: [
+ {
+ name: 'email',
+ label: fieldLabel,
+ required: true,
+ type: 'email',
+ },
+ ],
+};
+
+mockUseAuthenticator.mockReturnValue(mockUseAuthenticatorOutput as any);
+
+const props = {
+ className: '',
+ variation: 'default' as const,
+};
+
+describe('SetupEmail', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders as expected ', () => {
+ const mathRandomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.1);
+
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+
+ mathRandomSpy.mockRestore();
+ });
+
+ it('sends change event on form input', async () => {
+ render();
+
+ const emailField = await screen.findByLabelText(fieldLabel);
+
+ fireEvent.input(emailField, { target: fieldInput });
+
+ expect(mockUpdateForm).toHaveBeenCalledWith(fieldInput);
+ });
+
+ it('sends submit event on form submit', async () => {
+ render();
+
+ const emailField = await screen.findByLabelText(fieldLabel);
+
+ fireEvent.input(emailField, { target: fieldInput });
+
+ expect(mockUpdateForm).toHaveBeenCalledWith(fieldInput);
+
+ const submitButton = await screen.findByRole('button', { name: 'Confirm' });
+
+ fireEvent.click(submitButton);
+
+ expect(mockSubmitForm).toHaveBeenCalledTimes(1);
+ });
+
+ it('displays error if present', async () => {
+ mockUseAuthenticator.mockReturnValue({
+ ...mockUseAuthenticatorOutput,
+ error: 'mockError',
+ } as any);
+
+ render();
+
+ expect(await screen.findByText('mockError')).toBeInTheDocument();
+ });
+
+ it('handles back to sign in button as expected', async () => {
+ render();
+
+ const backToSignInButton = await screen.findByRole('button', {
+ name: 'Back to Sign In',
+ });
+
+ fireEvent.click(backToSignInButton);
+
+ expect(mockToSignIn).toHaveBeenCalledTimes(1);
+ });
+
+ it('disables the submit button if confirmation is pending', async () => {
+ mockUseAuthenticator.mockReturnValue({
+ ...mockUseAuthenticatorOutput,
+ isPending: true,
+ } as any);
+
+ render();
+
+ const submitButton = await screen.findByRole('button', {
+ name: 'Confirming',
+ });
+
+ expect(submitButton).toBeDisabled();
+ });
+});
diff --git a/packages/react/src/components/Authenticator/SetupEmail/__tests__/__snapshots__/SetupEmail.test.tsx.snap b/packages/react/src/components/Authenticator/SetupEmail/__tests__/__snapshots__/SetupEmail.test.tsx.snap
new file mode 100644
index 00000000000..7ad695b4231
--- /dev/null
+++ b/packages/react/src/components/Authenticator/SetupEmail/__tests__/__snapshots__/SetupEmail.test.tsx.snap
@@ -0,0 +1,80 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SetupEmail renders as expected 1`] = `
+
+`;
diff --git a/packages/react/src/components/Authenticator/SetupEmail/index.ts b/packages/react/src/components/Authenticator/SetupEmail/index.ts
new file mode 100644
index 00000000000..83c7fb415d8
--- /dev/null
+++ b/packages/react/src/components/Authenticator/SetupEmail/index.ts
@@ -0,0 +1 @@
+export * from './SetupEmail';
diff --git a/packages/react/src/components/Authenticator/hooks/useCustomComponents/defaultComponents.tsx b/packages/react/src/components/Authenticator/hooks/useCustomComponents/defaultComponents.tsx
index eb65ec883b9..21c7df89d39 100644
--- a/packages/react/src/components/Authenticator/hooks/useCustomComponents/defaultComponents.tsx
+++ b/packages/react/src/components/Authenticator/hooks/useCustomComponents/defaultComponents.tsx
@@ -6,6 +6,8 @@ import { SetupTotp } from '../../SetupTotp';
import { ConfirmSignIn } from '../../ConfirmSignIn/ConfirmSignIn';
import { ConfirmVerifyUser, VerifyUser } from '../../VerifyUser';
import { ConfirmResetPassword, ForgotPassword } from '../../ForgotPassword';
+import { SelectMfaType } from '../../SelectMfaType';
+import { SetupEmail } from '../../SetupEmail';
// use the very generic name of Components as this is a temporary interface and is not exported
interface Components {
@@ -25,6 +27,8 @@ export interface DefaultComponents extends Omit {
SignIn?: Omit;
SignUp?: Components;
VerifyUser?: Omit;
+ SelectMfaType?: Omit;
+ SetupEmail?: Omit;
}
export const defaultComponents: DefaultComponents = {
@@ -73,6 +77,14 @@ export const defaultComponents: DefaultComponents = {
Header: ForgotPassword.Header,
Footer: ForgotPassword.Footer,
},
+ SelectMfaType: {
+ Header: SelectMfaType.Header,
+ Footer: SelectMfaType.Footer,
+ },
+ SetupEmail: {
+ Header: SetupEmail.Header,
+ Footer: SetupEmail.Footer,
+ },
// @ts-ignore
Footer: (): React.JSX.Element => null,
};
diff --git a/packages/react/src/components/Authenticator/shared/FormField.tsx b/packages/react/src/components/Authenticator/shared/FormField.tsx
index 1e752bebda8..7f269afc50c 100644
--- a/packages/react/src/components/Authenticator/shared/FormField.tsx
+++ b/packages/react/src/components/Authenticator/shared/FormField.tsx
@@ -7,6 +7,7 @@ import { TextField } from '../../../primitives/TextField';
import { useAuthenticator } from '@aws-amplify/ui-react-core';
import { useStableId } from '../../../primitives/utils/useStableId';
import { ValidationErrors } from '../../shared/ValidationErrors';
+import { Radio, RadioGroupField } from '../../../primitives';
export interface FormFieldProps extends Omit {
// label is a required prop for the UI field components used in FormField
@@ -16,6 +17,7 @@ export interface FormFieldProps extends Omit {
export function FormField({
autocomplete: autoComplete,
+ radioOptions,
dialCode,
name,
type,
@@ -70,6 +72,23 @@ export function FormField({
/>
>
);
+ } else if (type === 'radio') {
+ return (
+ <>
+
+ {radioOptions?.map(({ label, value }) => (
+
+ {label}
+
+ ))}
+
+
+ >
+ );
} else {
return (
<>
diff --git a/packages/ui/src/helpers/authenticator/textUtil.ts b/packages/ui/src/helpers/authenticator/textUtil.ts
index d2617634c84..374e4898de6 100644
--- a/packages/ui/src/helpers/authenticator/textUtil.ts
+++ b/packages/ui/src/helpers/authenticator/textUtil.ts
@@ -88,7 +88,7 @@ const getSignInWithFederationText = (
*/
// TODO - i18n
const getSelectMfaTypeByChallengeName = (
- challengeName: ChallengeName
+ challengeName?: ChallengeName
): string => {
if (challengeName === 'MFA_SETUP') {
return translate(DefaultTexts.MFA_SETUP_SELECTION);
diff --git a/packages/ui/src/machines/authenticator/actors/signIn.ts b/packages/ui/src/machines/authenticator/actors/signIn.ts
index 16228a99295..592f7aa7d82 100644
--- a/packages/ui/src/machines/authenticator/actors/signIn.ts
+++ b/packages/ui/src/machines/authenticator/actors/signIn.ts
@@ -13,12 +13,12 @@ import actions from '../actions';
import { defaultServices } from '../defaultServices';
import guards from '../guards';
-import { AuthEvent, ActorDoneData, SignInContext } from '../types';
+import { AuthEvent, ActorDoneData, SignInContext, AuthContext } from '../types';
import { getFederatedSignInState } from './utils';
export interface SignInMachineOptions {
- services?: Partial;
+ services?: AuthContext['services'];
}
const handleSignInResponse = {
diff --git a/packages/ui/src/machines/authenticator/actors/signUp.ts b/packages/ui/src/machines/authenticator/actors/signUp.ts
index d4a0e64a6c7..08e742b43b6 100644
--- a/packages/ui/src/machines/authenticator/actors/signUp.ts
+++ b/packages/ui/src/machines/authenticator/actors/signUp.ts
@@ -1,26 +1,27 @@
import { createMachine, sendUpdate } from 'xstate';
import {
- autoSignIn,
ConfirmSignUpInput,
resendSignUpCode,
signInWithRedirect,
fetchUserAttributes,
+ autoSignIn,
} from 'aws-amplify/auth';
-import { AuthEvent, SignUpContext } from '../types';
+import { AuthContext, AuthEvent, SignUpContext } from '../types';
import { getSignUpInput } from '../utils';
import { runValidators } from '../../../validators';
import actions from '../actions';
-import { defaultServices } from '../defaultServices';
import guards from '../guards';
import { getFederatedSignInState } from './utils';
+// default autoSignIn reference from Amplify JS changes outside of state machine
+// avoid mutating context outside of state machine
export type SignUpMachineOptions = {
- services?: Partial;
+ services?: AuthContext['services'];
};
const handleResetPasswordResponse = {
@@ -278,7 +279,7 @@ export function signUpActor({ services }: SignUpMachineOptions) {
guards,
services: {
autoSignIn() {
- return autoSignIn();
+ return services.handleAutoSignIn?.() || autoSignIn();
},
async fetchUserAttributes() {
return fetchUserAttributes();
diff --git a/packages/ui/src/machines/authenticator/defaultServices.ts b/packages/ui/src/machines/authenticator/defaultServices.ts
index c7a3cb02940..00fed54e325 100644
--- a/packages/ui/src/machines/authenticator/defaultServices.ts
+++ b/packages/ui/src/machines/authenticator/defaultServices.ts
@@ -89,7 +89,6 @@ export const defaultServices = {
handleConfirmSignUp: confirmSignUp,
handleForgotPasswordSubmit: confirmResetPassword,
handleForgotPassword: resetPassword,
-
// Validation hooks for overriding
async validateCustomSignUp(
_: AuthFormData,
diff --git a/packages/ui/src/machines/authenticator/types.ts b/packages/ui/src/machines/authenticator/types.ts
index 0afec0c6197..b2133983af0 100644
--- a/packages/ui/src/machines/authenticator/types.ts
+++ b/packages/ui/src/machines/authenticator/types.ts
@@ -1,5 +1,5 @@
import { State } from 'xstate';
-import { AuthUser } from 'aws-amplify/auth';
+import { AuthUser, SignInOutput } from 'aws-amplify/auth';
import {
LoginMechanism,
@@ -135,7 +135,11 @@ export interface AuthContext {
initialState?: 'signIn' | 'signUp' | 'forgotPassword';
passwordSettings?: PasswordSettings;
};
- services?: Partial;
+ // default autoSignIn reference from Amplify JS changes outside of state machine
+ // avoid mutating context outside of state machine
+ services?: Partial & {
+ handleAutoSignIn?: () => Promise;
+ };
user?: AuthUser;
// data returned from actors when they finish and reach their final state
actorDoneData?: ActorDoneData;