diff --git a/packages/amplify_authenticator/example/integration_test/config.dart b/packages/amplify_authenticator/example/integration_test/config.dart index be64a03671..8dab7c617b 100644 --- a/packages/amplify_authenticator/example/integration_test/config.dart +++ b/packages/amplify_authenticator/example/integration_test/config.dart @@ -19,6 +19,7 @@ import 'package:amplify_flutter/amplify_flutter.dart'; import 'envs/auth_with_email.dart' as auth_with_email; import 'envs/auth_with_email_lambda_signup_trigger.dart' as auth_with_email_lambda_signup_trigger; +import 'envs/auth_with_email_or_phone.dart' as auth_with_email_or_phone; import 'envs/auth_with_phone.dart' as auth_with_phone; import 'envs/auth_with_username.dart' as auth_with_username; import 'pages/test_utils.dart'; @@ -38,6 +39,7 @@ const environmentsByConfiguration = { 'auth-with-email-lambda-signup-trigger', 'ui/components/authenticator/reset-password': 'auth-with-username', 'ui/components/authenticator/verify-user': 'auth-with-email', + 'email-or-phone': 'auth-with-email-or-phone' }; const environments = { @@ -46,6 +48,7 @@ const environments = { 'auth-with-username': auth_with_username.amplifyconfig, 'auth-with-email-lambda-signup-trigger': auth_with_email_lambda_signup_trigger.amplifyconfig, + 'auth-with-email-or-phone': auth_with_email_or_phone.amplifyconfig, }; Future loadConfiguration(String configurationName, diff --git a/packages/amplify_authenticator/example/integration_test/confirm_sign_up_email_or_phone_test.dart b/packages/amplify_authenticator/example/integration_test/confirm_sign_up_email_or_phone_test.dart new file mode 100644 index 0000000000..3370ef9593 --- /dev/null +++ b/packages/amplify_authenticator/example/integration_test/confirm_sign_up_email_or_phone_test.dart @@ -0,0 +1,167 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; + +import 'package:amplify_api/amplify_api.dart'; +import 'package:amplify_authenticator/amplify_authenticator.dart'; +import 'package:amplify_flutter/amplify_flutter.dart'; +import 'package:amplify_test/amplify_test.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'config.dart'; +import 'pages/confirm_sign_up_page.dart'; +import 'pages/sign_in_page.dart'; +import 'pages/sign_up_page.dart'; +import 'pages/test_utils.dart'; +import 'utils/mock_data.dart'; + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + // resolves issue on iOS. See: https://github.com/flutter/flutter/issues/89651 + binding.deferFirstFrame(); + + final isMobile = !kIsWeb && (Platform.isIOS || Platform.isAndroid); + + final authenticator = Authenticator( + child: MaterialApp( + builder: Authenticator.builder(), + home: const Scaffold( + body: Center( + child: SignOutButton(), + ), + ), + ), + ); + + group( + 'confirm-sign-up', + () { + // Given I'm running the example with an "Email or Phone" config + setUpAll(() async { + await loadConfiguration( + 'email-or-phone', + additionalConfigs: isMobile ? [AmplifyAPI()] : null, + ); + }); + + setUp(signOut); + + tearDown(Amplify.Auth.deleteUser); + + // Scenario: Sign up & confirm account with email as username + testWidgets( + 'Sign up & confirm account with email as username', + (tester) async { + final signUpPage = SignUpPage(tester: tester); + final confirmSignUpPage = ConfirmSignUpPage(tester: tester); + final signInPage = SignInPage(tester: tester); + + await loadAuthenticator(tester: tester, authenticator: authenticator); + + final email = generateEmail(); + final phoneNumber = generateUSPhoneNumber(); + final password = generatePassword(); + + final code = getOtpCode(email); + + await signInPage.navigateToSignUp(); + + // When I select email as a username + await signUpPage.selectEmail(); + + // And I type my email address as a username + await signUpPage.enterUsername(email); + + // And I type my password + await signUpPage.enterPassword(password); + + // And I confirm my password + await signUpPage.enterPasswordConfirmation(password); + + // And I enter my phone number + await signUpPage.enterPhoneNumber(phoneNumber.withOutCountryCode()); + + // And I click the "Create Account" button + await signUpPage.submitSignUp(); + + // And I see "Confirmation Code" + confirmSignUpPage.expectConfirmationCodeIsPresent(); + + // And I type a valid confirmation code + await confirmSignUpPage.enterCode(await code); + + // And I click the "Confirm" button + await confirmSignUpPage.submitConfirmSignUp(); + + // Then I see "Sign out" + await signInPage.expectAuthenticated(); + }, + ); + + testWidgets( + 'Sign up & confirm account with phone number as username', + (tester) async { + final signUpPage = SignUpPage(tester: tester); + final confirmSignUpPage = ConfirmSignUpPage(tester: tester); + final signInPage = SignInPage(tester: tester); + + await loadAuthenticator(tester: tester, authenticator: authenticator); + + final email = generateEmail(); + final phoneNumber = generateUSPhoneNumber(); + final password = generatePassword(); + + final code = getOtpCode(email); + + await signInPage.navigateToSignUp(); + + // When I select phone number as a username + await signUpPage.selectPhone(); + + // And I type my phone number as a username + await signUpPage.enterUsername(phoneNumber.withOutCountryCode()); + + // And I type my password + await signUpPage.enterPassword(password); + + // And I confirm my password + await signUpPage.enterPasswordConfirmation(password); + + // And I enter my email address + await signUpPage.enterEmail(email); + + // And I click the "Create Account" button + await signUpPage.submitSignUp(); + + // And I see "Confirmation Code" + confirmSignUpPage.expectConfirmationCodeIsPresent(); + + // And I type a valid confirmation code + await confirmSignUpPage.enterCode(await code); + + // And I click the "Confirm" button + await confirmSignUpPage.submitConfirmSignUp(); + + // Then I see "Sign out" + await signInPage.expectAuthenticated(); + }, + ); + }, + skip: !isMobile, + ); +} diff --git a/packages/amplify_authenticator/example/integration_test/envs/auth_with_email_or_phone.dart b/packages/amplify_authenticator/example/integration_test/envs/auth_with_email_or_phone.dart new file mode 100644 index 0000000000..b89aa450c9 --- /dev/null +++ b/packages/amplify_authenticator/example/integration_test/envs/auth_with_email_or_phone.dart @@ -0,0 +1,4 @@ +const amplifyconfig = ''' { + "UserAgent": "aws-amplify-cli/2.0", + "Version": "1.0" +}'''; diff --git a/packages/amplify_authenticator/example/integration_test/pages/sign_up_page.dart b/packages/amplify_authenticator/example/integration_test/pages/sign_up_page.dart index 669c7f4a1d..f1a4d998ab 100644 --- a/packages/amplify_authenticator/example/integration_test/pages/sign_up_page.dart +++ b/packages/amplify_authenticator/example/integration_test/pages/sign_up_page.dart @@ -32,6 +32,9 @@ class SignUpPage extends AuthenticatorPage { find.byKey(keyPreferredUsernameSignUpFormField); Finder get signUpButton => find.byKey(keySignUpButton); + Finder get selectEmailButton => find.byKey(keyEmailUsernameToggleButton); + Finder get selectPhoneButton => find.byKey(keyPhoneUsernameToggleButton); + /// When I type a new "username" Future enterUsername(String username) async { await tester.enterText(usernameField, username); @@ -52,6 +55,11 @@ class SignUpPage extends AuthenticatorPage { await tester.enterText(emailField, email); } + /// When I type my "PhoneNumber" + Future enterPhoneNumber(String value) async { + await tester.enterText(phoneField, value); + } + /// When I type a new "preferred username" Future enterPreferredUsername(String username) async { await tester.enterText(preferredUsernameField, username); @@ -64,6 +72,20 @@ class SignUpPage extends AuthenticatorPage { await tester.pumpAndSettle(); } + /// When I select "email" as a username + Future selectEmail() async { + await tester.ensureVisible(selectEmailButton); + await tester.tap(selectEmailButton); + await tester.pumpAndSettle(); + } + + /// When I select "phone" as a username + Future selectPhone() async { + await tester.ensureVisible(selectPhoneButton); + await tester.tap(selectPhoneButton); + await tester.pumpAndSettle(); + } + /// Then I see "Username" as an input field void expectUserNameIsPresent({String usernameLabel = 'Username'}) { // username field is present diff --git a/packages/amplify_authenticator/lib/amplify_authenticator.dart b/packages/amplify_authenticator/lib/amplify_authenticator.dart index 779d3b97fd..1092c7b7e1 100644 --- a/packages/amplify_authenticator/lib/amplify_authenticator.dart +++ b/packages/amplify_authenticator/lib/amplify_authenticator.dart @@ -55,7 +55,8 @@ export 'package:amplify_flutter/amplify_flutter.dart' export 'src/enums/enums.dart' show AuthenticatorStep, Gender; export 'src/l10n/auth_strings_resolver.dart' hide ButtonResolverKeyType; export 'src/models/authenticator_exception.dart'; -export 'src/models/username_input.dart' show UsernameType, UsernameInput; +export 'src/models/username_input.dart' + show UsernameType, UsernameInput, UsernameSelection; export 'src/state/authenticator_state.dart'; export 'src/widgets/button.dart' show diff --git a/packages/amplify_authenticator/lib/src/keys.dart b/packages/amplify_authenticator/lib/src/keys.dart index 5b898e6566..5c6e9110de 100644 --- a/packages/amplify_authenticator/lib/src/keys.dart +++ b/packages/amplify_authenticator/lib/src/keys.dart @@ -122,6 +122,8 @@ const keyForgotPasswordButton = Key('forgotPasswordButton'); const keySkipVerifyUserButton = Key('skipVerifyUserButton'); const keySubmitVerifyUserButton = Key('submitVerifyUserButton'); const keySubmitConfirmVerifyUserButton = Key('submitConfirmVerifyUserButton'); +const keyEmailUsernameToggleButton = Key('emailUsernameToggleButton'); +const keyPhoneUsernameToggleButton = Key('phoneUsernameToggleButton'); // Checkboxes keys diff --git a/packages/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart b/packages/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart index 3935acc4cc..ea4c16ea46 100644 --- a/packages/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart +++ b/packages/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart @@ -14,8 +14,10 @@ */ import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; +import 'package:amplify_authenticator/src/keys.dart'; import 'package:amplify_authenticator/src/l10n/auth_strings_resolver.dart'; import 'package:amplify_authenticator/src/models/username_input.dart'; +import 'package:amplify_authenticator/src/utils/country_code.dart'; import 'package:amplify_authenticator/src/utils/validators.dart'; import 'package:amplify_authenticator/src/widgets/component.dart'; import 'package:amplify_authenticator/src/widgets/form.dart'; @@ -129,23 +131,39 @@ mixin AuthenticatorUsernameField return {...?authConfig?.usernameAttributes}; }(); - /// Toggle value for the email or phone number case. - final ValueNotifier useEmail = ValueNotifier(true); - UsernameConfigType get usernameType { if (usernameAttributes.isEmpty) { return UsernameConfigType.username; @@ -283,7 +298,7 @@ mixin UsernameAttributes case UsernameConfigType.phoneNumber: return UsernameType.phoneNumber; case UsernameConfigType.emailOrPhoneNumber: - if (useEmail.value) { + if (state.usernameSelection == UsernameSelection.email) { return UsernameType.email; } return UsernameType.phoneNumber; diff --git a/packages/amplify_authenticator/lib/src/models/username_input.dart b/packages/amplify_authenticator/lib/src/models/username_input.dart index 7a00e5ba4d..b3cf9e7b9a 100644 --- a/packages/amplify_authenticator/lib/src/models/username_input.dart +++ b/packages/amplify_authenticator/lib/src/models/username_input.dart @@ -23,9 +23,10 @@ enum UsernameConfigType { } /// {@template amplify_authenticator.username_type} -/// The type of username input field presented to the user. Depending on your -/// Cognito configuration, users may choose to create their own username, use -/// their email, or use their phone number as their login. +/// The type of username input field presented to the user. +/// +/// Depending on your Cognito configuration, users will be required to either +/// create a unique username, or sign up with an email or phone number. /// {@endtemplate} enum UsernameType { /// The user's chosen username. @@ -57,3 +58,12 @@ class UsernameInput { required this.username, }); } + +/// {@template amplify_authenticator.username_input.username_selection} +/// The username type to use during sign up and sign in for configurations +/// that allow email OR phone number. +/// {@endtemplate} +enum UsernameSelection { + email, + phoneNumber, +} diff --git a/packages/amplify_authenticator/lib/src/state/authenticator_state.dart b/packages/amplify_authenticator/lib/src/state/authenticator_state.dart index e58444376d..128270eb2f 100644 --- a/packages/amplify_authenticator/lib/src/state/authenticator_state.dart +++ b/packages/amplify_authenticator/lib/src/state/authenticator_state.dart @@ -17,6 +17,7 @@ import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; import 'package:amplify_authenticator/amplify_authenticator.dart'; import 'package:amplify_authenticator/src/blocs/auth/auth_bloc.dart'; import 'package:amplify_authenticator/src/blocs/auth/auth_data.dart'; +import 'package:amplify_authenticator/src/models/username_input.dart'; import 'package:amplify_authenticator/src/state/auth_state.dart'; import 'package:amplify_authenticator/src/utils/country_code.dart'; import 'package:flutter/material.dart'; @@ -98,6 +99,21 @@ class AuthenticatorState extends ChangeNotifier { String _username = ''; + /// {@macro amplify_authenticator.username_input.username_selection} + /// + /// Defaults to [UsernameSelection.email]. + /// + /// The value has no meaning for Auth configurations that do not allow + /// for email OR phone number to be used as username. + UsernameSelection get usernameSelection => _usernameSelection; + + set usernameSelection(UsernameSelection value) { + _usernameSelection = value; + notifyListeners(); + } + + UsernameSelection _usernameSelection = UsernameSelection.email; + /// The value for the password form field /// /// This value will be used during sign up, sign in, or other actions diff --git a/packages/amplify_authenticator/lib/src/widgets/form.dart b/packages/amplify_authenticator/lib/src/widgets/form.dart index 6ecd48482e..48381c52a1 100644 --- a/packages/amplify_authenticator/lib/src/widgets/form.dart +++ b/packages/amplify_authenticator/lib/src/widgets/form.dart @@ -142,26 +142,6 @@ class AuthenticatorFormState final ValueNotifier obscureTextToggleValue = ValueNotifier(true); - @override - void initState() { - super.initState(); - useEmail.addListener(_updateUseEmail); - } - - @override - void dispose() { - useEmail.removeListener(_updateUseEmail); - super.dispose(); - } - - void _updateUseEmail() { - // Clear attributes on switch - state.authAttributes.clear(); - - // Refresh state - setState(() {}); - } - /// Controls optional visibility of the field. Widget get obscureTextToggle { return ValueListenableBuilder( diff --git a/packages/amplify_authenticator/lib/src/widgets/form_field.dart b/packages/amplify_authenticator/lib/src/widgets/form_field.dart index 2db233e383..cdca209459 100644 --- a/packages/amplify_authenticator/lib/src/widgets/form_field.dart +++ b/packages/amplify_authenticator/lib/src/widgets/form_field.dart @@ -139,10 +139,6 @@ abstract class AuthenticatorFormFieldState AuthenticatorFormState.of(context).selectedUsernameType; - @nonVirtual - ValueNotifier get useEmail => - AuthenticatorFormState.of(context).useEmail; - /// Callback for when `onChanged` is triggered on the [FormField]. ValueChanged get onChanged => (_) {}; @@ -254,8 +250,6 @@ abstract class AuthenticatorFormFieldState('usernameType', usernameType)); properties.add(EnumProperty( 'selectedUsernameType', selectedUsernameType)); - properties - .add(DiagnosticsProperty>('useEmail', useEmail)); properties.add(IntProperty('maxLength', maxLength)); properties.add(DiagnosticsProperty('isOptional', isOptional)); properties.add(StringProperty('labelText', labelText)); diff --git a/packages/amplify_test/lib/src/integration_test_utils/auth_cognito/integration_test_auth_utils.dart b/packages/amplify_test/lib/src/integration_test_utils/auth_cognito/integration_test_auth_utils.dart index acf4446512..746c21ecf5 100644 --- a/packages/amplify_test/lib/src/integration_test_utils/auth_cognito/integration_test_auth_utils.dart +++ b/packages/amplify_test/lib/src/integration_test_utils/auth_cognito/integration_test_auth_utils.dart @@ -178,9 +178,9 @@ Future adminCreateUser( } } -/// Returns the OTP code for [username]. Must be called before the network call +/// Returns the OTP code for [email]. Must be called before the network call /// generating the OTP code. -Future getOtpCode(String username) async { +Future getOtpCode(String email) async { const subscriptionDocument = '''subscription { onCreateConfirmSignUpTestRun { id @@ -200,7 +200,7 @@ Future getOtpCode(String username) async { jsonDecode(event.data!)['onCreateConfirmSignUpTestRun'] as Map; return ConfirmSignUpResponse.fromJson(json.cast()); }) - .where((event) => event.username == username) + .where((event) => event.username == email) .map((event) => event.currentCode) // When multiple Cognito events happen in a test, we must use the newest // code, since the others will have been invalidated.