diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt index d8270d89..45508517 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt @@ -24,6 +24,7 @@ import com.amplifyframework.auth.AuthUserAttribute import com.amplifyframework.auth.MFAType import com.amplifyframework.auth.result.AuthSignOutResult import com.amplifyframework.auth.result.AuthWebAuthnCredential +import com.amplifyframework.ui.authenticator.enums.AuthFactor import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep import com.amplifyframework.ui.authenticator.forms.MutableFormState @@ -94,6 +95,68 @@ interface SignInState : AuthenticatorStepState { suspend fun signIn() } +/** + * The user has entered their username and must select the authentication factor they'd like to use to sign in + */ +@Stable +interface SignInSelectAuthFactorState : AuthenticatorStepState { + /** + * The input form state holder for this step. + */ + val form: MutableFormState + + /** + * The username entered in the SignIn step + */ + val username: String + + /** + * The available types to select how to sign in. + */ + val availableAuthFactors: Set + + /** + * The factor the user selected and is currently being processed + */ + val selectedFactor: AuthFactor? + + /** + * Move the user to a different [AuthenticatorInitialStep]. + */ + fun moveTo(step: AuthenticatorInitialStep) + + /** + * Initiate a sign in with one of the available sign in types + */ + suspend fun select(authFactor: AuthFactor) +} + +/** + * A user has entered their username and must enter their password to continue signing in + */ +@Stable +interface SignInConfirmPasswordState : AuthenticatorStepState { + /** + * The input form state holder for this step. + */ + val form: MutableFormState + + /** + * The username entered in the SignIn step + */ + val username: String + + /** + * Move the user to a different [AuthenticatorInitialStep]. + */ + fun moveTo(step: AuthenticatorInitialStep) + + /** + * Initiate a sign in with the information entered into the [form]. + */ + suspend fun signIn() +} + /** * The user has completed the initial Sign In step, and needs to enter the confirmation code from an MFA * message to complete the sign in process. diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/data/AuthFactor.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/data/AuthFactor.kt new file mode 100644 index 00000000..f2dca046 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/data/AuthFactor.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2025 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. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +package com.amplifyframework.ui.authenticator.enums + +import com.amplifyframework.auth.AuthFactorType + +sealed interface AuthFactor { + data class Password(val srp: Boolean = true) : AuthFactor + data object EmailOtp : AuthFactor + data object SmsOtp : AuthFactor + data object WebAuthn : AuthFactor +} + +internal fun AuthFactor.toAuthFactorType() = when (this) { + AuthFactor.EmailOtp -> AuthFactorType.EMAIL_OTP + AuthFactor.SmsOtp -> AuthFactorType.SMS_OTP + AuthFactor.WebAuthn -> AuthFactorType.WEB_AUTHN + is AuthFactor.Password -> if (srp) AuthFactorType.PASSWORD_SRP else AuthFactorType.PASSWORD +} + +internal fun AuthFactorType.toAuthFactor() = when (this) { + AuthFactorType.PASSWORD -> AuthFactor.Password(srp = false) + AuthFactorType.PASSWORD_SRP -> AuthFactor.Password(srp = true) + AuthFactorType.EMAIL_OTP -> AuthFactor.EmailOtp + AuthFactorType.SMS_OTP -> AuthFactor.SmsOtp + AuthFactorType.WEB_AUTHN -> AuthFactor.WebAuthn +} + +internal val AuthFactor.challengeResponse: String + get() = this.toAuthFactorType().challengeResponse + +internal fun Collection.toAuthFactors(): Set { + // If both SRP and password are available then use SRP to sign in + var factors = this + if (this.contains(AuthFactorType.PASSWORD) && this.contains(AuthFactorType.PASSWORD_SRP)) { + factors = this - AuthFactorType.PASSWORD // remove password + } + return factors.map { it.toAuthFactor() }.toSet() +} +internal fun Collection.containsPassword() = any { it is AuthFactor.Password } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/AuthenticatorStep.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/AuthenticatorStep.kt index e6379b75..e691ef2b 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/AuthenticatorStep.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/AuthenticatorStep.kt @@ -46,6 +46,16 @@ abstract class AuthenticatorStep internal constructor() { */ object SignIn : AuthenticatorInitialStep() + /** + * The user has entered their username and must select the authentication factor they'd like to use to sign in + */ + object SignInSelectAuthFactor : AuthenticatorStep() + + /** + * A user has entered their username and must enter their password to continue signing in + */ + object SignInConfirmPassword : AuthenticatorStep() + /** * The user has completed the initial Sign In step, and needs to enter the confirmation code from a custom * challenge to complete the sign in process. diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInConfirmPasswordStateImpl.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInConfirmPasswordStateImpl.kt new file mode 100644 index 00000000..d8c49bfb --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInConfirmPasswordStateImpl.kt @@ -0,0 +1,33 @@ +package com.amplifyframework.ui.authenticator.states + +import com.amplifyframework.ui.authenticator.SignInConfirmPasswordState +import com.amplifyframework.ui.authenticator.auth.SignInMethod +import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep +import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep +import com.amplifyframework.ui.authenticator.forms.FieldKey + +internal class SignInConfirmPasswordStateImpl( + override val username: String, + val signInMethod: SignInMethod, + private val onSubmit: suspend (password: String) -> Unit, + private val onMoveTo: (step: AuthenticatorInitialStep) -> Unit +) : BaseStateImpl(), + SignInConfirmPasswordState { + + init { + form.addFields { + password() + } + } + + override val step: AuthenticatorStep = AuthenticatorStep.SignInConfirmPassword + override fun moveTo(step: AuthenticatorInitialStep) = onMoveTo(step) + + override suspend fun signIn() = doSubmit { + val password = form.getTrimmed(FieldKey.Password)!! + onSubmit(password) + } +} + +internal val SignInConfirmPasswordState.signInMethod: SignInMethod + get() = (this as SignInConfirmPasswordStateImpl).signInMethod diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInSelectAuthFactorStateImpl.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInSelectAuthFactorStateImpl.kt new file mode 100644 index 00000000..d0601eb7 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInSelectAuthFactorStateImpl.kt @@ -0,0 +1,49 @@ +package com.amplifyframework.ui.authenticator.states + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.amplifyframework.ui.authenticator.SignInSelectAuthFactorState +import com.amplifyframework.ui.authenticator.auth.SignInMethod +import com.amplifyframework.ui.authenticator.enums.AuthFactor +import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep +import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep +import com.amplifyframework.ui.authenticator.enums.containsPassword + +internal class SignInSelectAuthFactorStateImpl( + override val username: String, + val signInMethod: SignInMethod, + override val availableAuthFactors: Set, + private val onSubmit: suspend (authFactor: AuthFactor) -> Unit, + private val onMoveTo: (step: AuthenticatorInitialStep) -> Unit +) : BaseStateImpl(), + SignInSelectAuthFactorState { + override val step: AuthenticatorStep = AuthenticatorStep.SignInSelectAuthFactor + + override var selectedFactor: AuthFactor? by mutableStateOf(null) + + init { + if (availableAuthFactors.containsPassword()) { + form.addFields { password() } + } + } + + override fun moveTo(step: AuthenticatorInitialStep) = onMoveTo(step) + + override suspend fun select(authFactor: AuthFactor) { + // Clear errors + form.fields.values.forEach { it.state.error = null } + + selectedFactor = authFactor + form.enabled = false + onSubmit(authFactor) + form.enabled = true + selectedFactor = null + } +} + +internal fun SignInSelectAuthFactorState.getPasswordFactor(): AuthFactor = + availableAuthFactors.first { it is AuthFactor.Password } + +internal val SignInSelectAuthFactorState.signInMethod: SignInMethod + get() = (this as SignInSelectAuthFactorStateImpl).signInMethod \ No newline at end of file diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/strings/StringResolver.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/strings/StringResolver.kt index 2626cc9b..8964b46e 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/strings/StringResolver.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/strings/StringResolver.kt @@ -32,7 +32,7 @@ internal open class StringResolver { @Composable @ReadOnlyComposable open fun label(config: FieldConfig): String { - var label = title(config) + var label = fieldName(config) if (!config.required) { label = stringResource(R.string.amplify_ui_authenticator_field_optional, label) } @@ -41,118 +41,116 @@ internal open class StringResolver { @Composable @ReadOnlyComposable - private fun title(config: FieldConfig): String { - return config.label ?: when (config.key) { - FieldKey.ConfirmPassword -> stringResource(R.string.amplify_ui_authenticator_field_label_password_confirm) - FieldKey.ConfirmationCode -> stringResource(R.string.amplify_ui_authenticator_field_label_confirmation_code) - FieldKey.Password -> stringResource(R.string.amplify_ui_authenticator_field_label_password) - FieldKey.PhoneNumber -> stringResource(R.string.amplify_ui_authenticator_field_label_phone_number) - FieldKey.Email -> stringResource(R.string.amplify_ui_authenticator_field_label_email) - FieldKey.Username -> stringResource(R.string.amplify_ui_authenticator_field_label_username) - FieldKey.Birthdate -> stringResource(R.string.amplify_ui_authenticator_field_label_birthdate) - FieldKey.FamilyName -> stringResource(R.string.amplify_ui_authenticator_field_label_family_name) - FieldKey.GivenName -> stringResource(R.string.amplify_ui_authenticator_field_label_given_name) - FieldKey.MiddleName -> stringResource(R.string.amplify_ui_authenticator_field_label_middle_name) - FieldKey.Name -> stringResource(R.string.amplify_ui_authenticator_field_label_name) - FieldKey.Website -> stringResource(R.string.amplify_ui_authenticator_field_label_website) - FieldKey.PhoneNumber -> stringResource(R.string.amplify_ui_authenticator_field_label_phone_number) - FieldKey.Nickname -> stringResource(R.string.amplify_ui_authenticator_field_label_nickname) - FieldKey.PreferredUsername -> - stringResource(R.string.amplify_ui_authenticator_field_label_preferred_username) - FieldKey.Profile -> stringResource(R.string.amplify_ui_authenticator_field_label_profile) - FieldKey.VerificationAttribute -> - stringResource(R.string.amplify_ui_authenticator_field_label_verification_attribute) - else -> "" - } + private fun fieldName(config: FieldConfig): String = config.label ?: fieldName(config.key) + + @Composable + @ReadOnlyComposable + fun fieldName(key: FieldKey): String = when (key) { + FieldKey.ConfirmPassword -> stringResource(R.string.amplify_ui_authenticator_field_label_password_confirm) + FieldKey.ConfirmationCode -> stringResource(R.string.amplify_ui_authenticator_field_label_confirmation_code) + FieldKey.Password -> stringResource(R.string.amplify_ui_authenticator_field_label_password) + FieldKey.PhoneNumber -> stringResource(R.string.amplify_ui_authenticator_field_label_phone_number) + FieldKey.Email -> stringResource(R.string.amplify_ui_authenticator_field_label_email) + FieldKey.Username -> stringResource(R.string.amplify_ui_authenticator_field_label_username) + FieldKey.Birthdate -> stringResource(R.string.amplify_ui_authenticator_field_label_birthdate) + FieldKey.FamilyName -> stringResource(R.string.amplify_ui_authenticator_field_label_family_name) + FieldKey.GivenName -> stringResource(R.string.amplify_ui_authenticator_field_label_given_name) + FieldKey.MiddleName -> stringResource(R.string.amplify_ui_authenticator_field_label_middle_name) + FieldKey.Name -> stringResource(R.string.amplify_ui_authenticator_field_label_name) + FieldKey.Website -> stringResource(R.string.amplify_ui_authenticator_field_label_website) + FieldKey.PhoneNumber -> stringResource(R.string.amplify_ui_authenticator_field_label_phone_number) + FieldKey.Nickname -> stringResource(R.string.amplify_ui_authenticator_field_label_nickname) + FieldKey.PreferredUsername -> + stringResource(R.string.amplify_ui_authenticator_field_label_preferred_username) + FieldKey.Profile -> stringResource(R.string.amplify_ui_authenticator_field_label_profile) + FieldKey.VerificationAttribute -> + stringResource(R.string.amplify_ui_authenticator_field_label_verification_attribute) + else -> "" } @Composable @ReadOnlyComposable - open fun hint(config: FieldConfig): String? { - return config.hint ?: when { - config.key == FieldKey.ConfirmPassword -> - stringResource(R.string.amplify_ui_authenticator_field_hint_password_confirm) - config is FieldConfig.Date -> "yyyy-mm-dd" - else -> { - val label = label(config) - stringResource(R.string.amplify_ui_authenticator_field_hint, label) - } + open fun hint(config: FieldConfig): String? = config.hint ?: when { + config.key == FieldKey.ConfirmPassword -> + stringResource(R.string.amplify_ui_authenticator_field_hint_password_confirm) + config is FieldConfig.Date -> "yyyy-mm-dd" + else -> { + val label = label(config) + stringResource(R.string.amplify_ui_authenticator_field_hint, label) } } @OptIn(ExperimentalComposeUiApi::class) @Composable @ReadOnlyComposable - open fun error(config: FieldConfig, error: FieldError): String { - return when (error) { - is FieldError.InvalidPassword -> { - var errorText = stringResource(R.string.amplify_ui_authenticator_field_password_requirements) - error.errors.forEach { - errorText += "\n" + when (it) { - is PasswordError.InvalidPasswordLength -> - pluralStringResource( - id = R.plurals.amplify_ui_authenticator_field_password_too_short, - count = it.minimumLength, - it.minimumLength - ) - PasswordError.InvalidPasswordMissingSpecial -> - stringResource(R.string.amplify_ui_authenticator_field_password_missing_special) - PasswordError.InvalidPasswordMissingNumber -> - stringResource(R.string.amplify_ui_authenticator_field_password_missing_number) - PasswordError.InvalidPasswordMissingUpper -> - stringResource(R.string.amplify_ui_authenticator_field_password_missing_upper) - PasswordError.InvalidPasswordMissingLower -> - stringResource(R.string.amplify_ui_authenticator_field_password_missing_lower) - else -> "" - } + open fun error(config: FieldConfig, error: FieldError): String = when (error) { + is FieldError.InvalidPassword -> { + var errorText = stringResource(R.string.amplify_ui_authenticator_field_password_requirements) + error.errors.forEach { + errorText += "\n" + when (it) { + is PasswordError.InvalidPasswordLength -> + pluralStringResource( + id = R.plurals.amplify_ui_authenticator_field_password_too_short, + count = it.minimumLength, + it.minimumLength + ) + PasswordError.InvalidPasswordMissingSpecial -> + stringResource(R.string.amplify_ui_authenticator_field_password_missing_special) + PasswordError.InvalidPasswordMissingNumber -> + stringResource(R.string.amplify_ui_authenticator_field_password_missing_number) + PasswordError.InvalidPasswordMissingUpper -> + stringResource(R.string.amplify_ui_authenticator_field_password_missing_upper) + PasswordError.InvalidPasswordMissingLower -> + stringResource(R.string.amplify_ui_authenticator_field_password_missing_lower) + else -> "" } - errorText - } - FieldError.PasswordsDoNotMatch -> - stringResource(R.string.amplify_ui_authenticator_field_warn_unmatched_password) - FieldError.MissingRequired -> { - val label = title(config) - stringResource(R.string.amplify_ui_authenticator_field_warn_empty, label) - } - FieldError.InvalidFormat -> { - val label = title(config) - stringResource(R.string.amplify_ui_authenticator_field_warn_invalid_format, label) - } - FieldError.FieldValueExists -> { - val label = title(config) - stringResource(R.string.amplify_ui_authenticator_field_warn_existing, label) - } - FieldError.ConfirmationCodeIncorrect -> { - stringResource(R.string.amplify_ui_authenticator_field_warn_incorrect_code) - } - is FieldError.Custom -> error.message - FieldError.NotFound -> { - val label = title(config) - stringResource(R.string.amplify_ui_authenticator_field_warn_not_found, label) } - else -> "" + errorText } + FieldError.PasswordsDoNotMatch -> + stringResource(R.string.amplify_ui_authenticator_field_warn_unmatched_password) + FieldError.MissingRequired -> { + val label = fieldName(config) + stringResource(R.string.amplify_ui_authenticator_field_warn_empty, label) + } + FieldError.InvalidFormat -> { + val label = fieldName(config) + stringResource(R.string.amplify_ui_authenticator_field_warn_invalid_format, label) + } + FieldError.FieldValueExists -> { + val label = fieldName(config) + stringResource(R.string.amplify_ui_authenticator_field_warn_existing, label) + } + FieldError.ConfirmationCodeIncorrect -> { + stringResource(R.string.amplify_ui_authenticator_field_warn_incorrect_code) + } + is FieldError.Custom -> error.message + FieldError.NotFound -> { + val label = fieldName(config) + stringResource(R.string.amplify_ui_authenticator_field_warn_not_found, label) + } + else -> "" } @Suppress("UNUSED_EXPRESSION") @Composable @ReadOnlyComposable - open fun error(error: AuthException): String { - return when (error) { - else -> stringResource(R.string.amplify_ui_authenticator_error_unknown) - } + open fun error(error: AuthException): String = when (error) { + else -> stringResource(R.string.amplify_ui_authenticator_error_unknown) } companion object { @Composable @ReadOnlyComposable - fun label(config: FieldConfig) = - LocalStringResolver.current.label(config = config) + fun label(config: FieldConfig) = LocalStringResolver.current.label(config = config) + + @Composable + @ReadOnlyComposable + fun fieldName(key: FieldKey) = LocalStringResolver.current.fieldName(key = key) @Composable @ReadOnlyComposable - fun hint(config: FieldConfig) = - LocalStringResolver.current.hint(config = config) + fun hint(config: FieldConfig) = LocalStringResolver.current.hint(config = config) @Composable @ReadOnlyComposable @@ -161,7 +159,6 @@ internal open class StringResolver { @Composable @ReadOnlyComposable - fun error(error: AuthException) = - LocalStringResolver.current.error(error = error) + fun error(error: AuthException) = LocalStringResolver.current.error(error = error) } } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/Authenticator.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/Authenticator.kt index ef00f113..9f378142 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/Authenticator.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/Authenticator.kt @@ -41,16 +41,20 @@ import com.amplifyframework.ui.authenticator.AuthenticatorState import com.amplifyframework.ui.authenticator.AuthenticatorStepState import com.amplifyframework.ui.authenticator.ErrorState import com.amplifyframework.ui.authenticator.LoadingState +import com.amplifyframework.ui.authenticator.PasskeyCreatedState +import com.amplifyframework.ui.authenticator.PasskeyCreationPromptState import com.amplifyframework.ui.authenticator.PasswordResetConfirmState import com.amplifyframework.ui.authenticator.PasswordResetState import com.amplifyframework.ui.authenticator.SignInConfirmCustomState import com.amplifyframework.ui.authenticator.SignInConfirmMfaState import com.amplifyframework.ui.authenticator.SignInConfirmNewPasswordState +import com.amplifyframework.ui.authenticator.SignInConfirmPasswordState import com.amplifyframework.ui.authenticator.SignInConfirmTotpCodeState import com.amplifyframework.ui.authenticator.SignInContinueWithEmailSetupState import com.amplifyframework.ui.authenticator.SignInContinueWithMfaSelectionState import com.amplifyframework.ui.authenticator.SignInContinueWithMfaSetupSelectionState import com.amplifyframework.ui.authenticator.SignInContinueWithTotpSetupState +import com.amplifyframework.ui.authenticator.SignInSelectAuthFactorState import com.amplifyframework.ui.authenticator.SignInState import com.amplifyframework.ui.authenticator.SignUpConfirmState import com.amplifyframework.ui.authenticator.SignUpState @@ -147,6 +151,7 @@ fun Authenticator( when (targetState) { is LoadingState -> loadingContent() is SignInState -> signInContent(targetState) + is SignInSelectAuthFactorState -> SignInSelectAuthFactor(targetState) is SignInConfirmMfaState -> signInConfirmMfaContent(targetState) is SignInConfirmCustomState -> signInConfirmCustomContent(targetState) is SignInConfirmNewPasswordState -> signInConfirmNewPasswordContent( @@ -167,7 +172,10 @@ fun Authenticator( is SignUpConfirmState -> signUpConfirmContent(targetState) is VerifyUserState -> verifyUserContent(targetState) is VerifyUserConfirmState -> verifyUserConfirmContent(targetState) - else -> Unit + is PasskeyCreationPromptState -> PasskeyPrompt(targetState) + is PasskeyCreatedState -> PasskeyCreated(targetState) + is SignInConfirmPasswordState -> SignInConfirmPassword(targetState) + else -> error("Unimplemented state") } footerContent() } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/CommonFooter.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/CommonFooter.kt index 381f4051..d4be20f8 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/CommonFooter.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/CommonFooter.kt @@ -14,7 +14,8 @@ import com.amplifyframework.ui.authenticator.R @Composable internal fun BackToSignInFooter( onClickBackToSignIn: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + label: String = stringResource(R.string.amplify_ui_authenticator_button_back_to_signin) ) { Box( modifier = modifier.fillMaxWidth(), @@ -24,7 +25,7 @@ internal fun BackToSignInFooter( modifier = Modifier.testTag(TestTags.BackToSignInButton), onClick = onClickBackToSignIn ) { - Text(stringResource(R.string.amplify_ui_authenticator_button_back_to_signin)) + Text(label) } } } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/DividerWithText.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/DividerWithText.kt new file mode 100644 index 00000000..7ae67fe8 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/DividerWithText.kt @@ -0,0 +1,47 @@ +package com.amplifyframework.ui.authenticator.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +internal fun DividerWithText( + text: String, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.bodyMedium, + textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + dividerColor: Color = MaterialTheme.colorScheme.outline, + thickness: Dp = 1.dp, + textPadding: Dp = 16.dp +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + HorizontalDivider( + modifier = Modifier.weight(1f), + color = dividerColor, + thickness = thickness + ) + Text( + text = text, + style = textStyle, + color = textColor, + modifier = Modifier.padding(horizontal = textPadding) + ) + HorizontalDivider( + modifier = Modifier.weight(1f), + color = dividerColor, + thickness = thickness + ) + } +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInConfirmPassword.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInConfirmPassword.kt new file mode 100644 index 00000000..252a6f9f --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInConfirmPassword.kt @@ -0,0 +1,70 @@ +package com.amplifyframework.ui.authenticator.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.amplifyframework.ui.authenticator.R +import com.amplifyframework.ui.authenticator.SignInConfirmPasswordState +import com.amplifyframework.ui.authenticator.auth.toFieldKey +import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep +import com.amplifyframework.ui.authenticator.forms.FieldKey +import com.amplifyframework.ui.authenticator.states.signInMethod +import com.amplifyframework.ui.authenticator.strings.StringResolver +import com.amplifyframework.ui.authenticator.util.AuthenticatorUiConstants +import kotlinx.coroutines.launch + +@Composable +fun SignInConfirmPassword( + state: SignInConfirmPasswordState, + modifier: Modifier = Modifier, + headerContent: @Composable (state: SignInConfirmPasswordState) -> Unit = { + AuthenticatorTitle(stringResource(R.string.amplify_ui_authenticator_title_signin_confirm_password)) + }, + footerContent: @Composable (state: SignInConfirmPasswordState) -> Unit = { SignInConfirmPasswordFooter(it) } +) { + val scope = rememberCoroutineScope() + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + headerContent(state) + val usernameLabel = StringResolver.fieldName(state.signInMethod.toFieldKey()) + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .testTag(FieldKey.Username.testTag), + value = state.username, + onValueChange = {}, + label = { Text(usernameLabel) }, + readOnly = true + ) + Spacer(modifier = Modifier.size(AuthenticatorUiConstants.spaceBetweenFields)) + AuthenticatorForm( + state = state.form + ) + AuthenticatorButton( + onClick = { scope.launch { state.signIn() } }, + loading = !state.form.enabled, + label = stringResource(R.string.amplify_ui_authenticator_button_signin), + modifier = Modifier.testTag(TestTags.SignInButton) + ) + footerContent(state) + } +} + +@Composable +fun SignInConfirmPasswordFooter(state: SignInConfirmPasswordState, modifier: Modifier = Modifier) = BackToSignInFooter( + modifier = modifier, + onClickBackToSignIn = { state.moveTo(AuthenticatorStep.SignIn) } +) diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactor.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactor.kt new file mode 100644 index 00000000..e7baf5e8 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactor.kt @@ -0,0 +1,109 @@ +package com.amplifyframework.ui.authenticator.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.amplifyframework.ui.authenticator.R +import com.amplifyframework.ui.authenticator.SignInSelectAuthFactorState +import com.amplifyframework.ui.authenticator.auth.toFieldKey +import com.amplifyframework.ui.authenticator.enums.AuthFactor +import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep +import com.amplifyframework.ui.authenticator.enums.containsPassword +import com.amplifyframework.ui.authenticator.forms.FieldKey +import com.amplifyframework.ui.authenticator.states.getPasswordFactor +import com.amplifyframework.ui.authenticator.states.signInMethod +import com.amplifyframework.ui.authenticator.strings.StringResolver +import com.amplifyframework.ui.authenticator.util.AuthenticatorUiConstants +import kotlinx.coroutines.launch + +@Composable +fun SignInSelectAuthFactor( + state: SignInSelectAuthFactorState, + modifier: Modifier = Modifier, + headerContent: @Composable (SignInSelectAuthFactorState) -> Unit = { + AuthenticatorTitle(stringResource(R.string.amplify_ui_authenticator_title_select_factor)) + }, + footerContent: @Composable (SignInSelectAuthFactorState) -> Unit = { SignInSelectFactorFooter(it) } +) { + Column( + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp) + ) { + headerContent(state) + + val usernameLabel = StringResolver.fieldName(state.signInMethod.toFieldKey()) + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .testTag(FieldKey.Username.testTag), + value = state.username, + onValueChange = {}, + label = { Text(usernameLabel) }, + enabled = false + ) + Spacer(modifier = Modifier.size(AuthenticatorUiConstants.spaceBetweenFields)) + AuthenticatorForm( + state = state.form + ) + + if (state.availableAuthFactors.containsPassword()) { + AuthFactorButton(authFactor = state.getPasswordFactor(), state = state) + if (state.availableAuthFactors.size > 1) { + DividerWithText( + text = stringResource(R.string.amplify_ui_authenticator_or), + modifier = Modifier.fillMaxWidth() + ) + } + } + + if (state.availableAuthFactors.contains(AuthFactor.WebAuthn)) { + AuthFactorButton(authFactor = AuthFactor.WebAuthn, state = state) + } + if (state.availableAuthFactors.contains(AuthFactor.EmailOtp)) { + AuthFactorButton(authFactor = AuthFactor.EmailOtp, state = state) + } + if (state.availableAuthFactors.contains(AuthFactor.SmsOtp)) { + AuthFactorButton(authFactor = AuthFactor.SmsOtp, state = state) + } + footerContent(state) + } +} + +@Composable +fun SignInSelectFactorFooter(state: SignInSelectAuthFactorState, modifier: Modifier = Modifier) = BackToSignInFooter( + modifier = modifier, + onClickBackToSignIn = { state.moveTo(AuthenticatorStep.SignIn) } +) + +@Composable +private fun AuthFactorButton( + authFactor: AuthFactor, + state: SignInSelectAuthFactorState, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + AuthenticatorButton( + onClick = { scope.launch { state.select(authFactor) } }, + loading = state.selectedFactor == authFactor, + enabled = state.selectedFactor == null, + label = stringResource(authFactor.signInResourceId), + modifier = modifier.testTag(authFactor.testTag) + ) +} + +private val AuthFactor.signInResourceId: Int + get() = when (this) { + is AuthFactor.Password -> R.string.amplify_ui_authenticator_button_signin_password + AuthFactor.SmsOtp -> R.string.amplify_ui_authenticator_button_signin_sms + AuthFactor.EmailOtp -> R.string.amplify_ui_authenticator_button_signin_email + AuthFactor.WebAuthn -> R.string.amplify_ui_authenticator_button_signin_passkey + } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/TestTags.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/TestTags.kt index a5e00e11..5df6ca97 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/TestTags.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/TestTags.kt @@ -15,6 +15,7 @@ package com.amplifyframework.ui.authenticator.ui +import com.amplifyframework.ui.authenticator.enums.AuthFactor import com.amplifyframework.ui.authenticator.forms.FieldKey @Suppress("ConstPropertyName") @@ -32,8 +33,21 @@ internal object TestTags { const val SkipPasskeyButton = "SkipPasskeyButton" const val AuthenticatorTitle = "AuthenticatorTitle" + const val AuthFactorPassword = "AuthFactorPassword" + const val AuthFactorSms = "AuthFactorSms" + const val AuthFactorEmail = "AuthFactorEmail" + const val AuthFactorPasskey = "AuthFactorPasskey" + const val ShowPasswordIcon = "ShowPasswordIcon" } internal val FieldKey.testTag: String get() = this.toString() + +internal val AuthFactor.testTag: String + get() = when (this) { + is AuthFactor.Password -> TestTags.AuthFactorPassword + AuthFactor.SmsOtp -> TestTags.AuthFactorSms + AuthFactor.EmailOtp -> TestTags.AuthFactorEmail + AuthFactor.WebAuthn -> TestTags.AuthFactorPasskey + } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthenticatorUiConstants.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthenticatorUiConstants.kt new file mode 100644 index 00000000..4a27ed17 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthenticatorUiConstants.kt @@ -0,0 +1,7 @@ +package com.amplifyframework.ui.authenticator.util + +import androidx.compose.ui.unit.dp + +internal object AuthenticatorUiConstants { + val spaceBetweenFields = 8.dp +} diff --git a/authenticator/src/main/res/values/buttons.xml b/authenticator/src/main/res/values/buttons.xml index 4e6a6cab..4991c7e8 100644 --- a/authenticator/src/main/res/values/buttons.xml +++ b/authenticator/src/main/res/values/buttons.xml @@ -18,6 +18,10 @@ Submit Continue Sign In + Sign In with Password + Sign In with SMS + Sign In with Email + Sign In with Passkey Change Password Create Account Forgot Password? @@ -26,6 +30,7 @@ Send Code Skip Copy Key + Start Over Create a Passkey Continue without a Passkey diff --git a/authenticator/src/main/res/values/strings.xml b/authenticator/src/main/res/values/strings.xml index 07db7eed..0924a7f8 100644 --- a/authenticator/src/main/res/values/strings.xml +++ b/authenticator/src/main/res/values/strings.xml @@ -42,4 +42,7 @@ Existing Passkeys + + + or diff --git a/authenticator/src/main/res/values/titles.xml b/authenticator/src/main/res/values/titles.xml index 213f47f1..5ac623bb 100644 --- a/authenticator/src/main/res/values/titles.xml +++ b/authenticator/src/main/res/values/titles.xml @@ -16,6 +16,8 @@ Sign In + Choose how to sign in + Enter your password Verify your sign-in Change your password to sign in Create Account diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/testUtil/MockStates.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/testUtil/MockStates.kt index f5e812d4..2c2bda98 100644 --- a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/testUtil/MockStates.kt +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/testUtil/MockStates.kt @@ -21,6 +21,7 @@ import com.amplifyframework.auth.MFAType import com.amplifyframework.auth.result.AuthWebAuthnCredential import com.amplifyframework.ui.authenticator.auth.PasswordCriteria import com.amplifyframework.ui.authenticator.auth.SignInMethod +import com.amplifyframework.ui.authenticator.enums.AuthFactor import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep import com.amplifyframework.ui.authenticator.forms.FormData import com.amplifyframework.ui.authenticator.mockAuthCodeDeliveryDetails @@ -29,11 +30,13 @@ import com.amplifyframework.ui.authenticator.states.PasskeyCreationPromptStateIm import com.amplifyframework.ui.authenticator.states.PasswordResetConfirmStateImpl import com.amplifyframework.ui.authenticator.states.PasswordResetStateImpl import com.amplifyframework.ui.authenticator.states.SignInConfirmMfaStateImpl +import com.amplifyframework.ui.authenticator.states.SignInConfirmPasswordStateImpl import com.amplifyframework.ui.authenticator.states.SignInConfirmTotpCodeStateImpl import com.amplifyframework.ui.authenticator.states.SignInContinueWithEmailSetupStateImpl import com.amplifyframework.ui.authenticator.states.SignInContinueWithMfaSelectionStateImpl import com.amplifyframework.ui.authenticator.states.SignInContinueWithMfaSetupSelectionStateImpl import com.amplifyframework.ui.authenticator.states.SignInContinueWithTotpSetupStateImpl +import com.amplifyframework.ui.authenticator.states.SignInSelectAuthFactorStateImpl import com.amplifyframework.ui.authenticator.states.SignInStateImpl import com.amplifyframework.ui.authenticator.states.SignUpStateImpl @@ -136,3 +139,30 @@ internal fun mockPasskeyCreationPromptState(onSubmit: suspend () -> Unit = {}, o onSubmit = onSubmit, onSkip = onSkip ) + +internal fun mockSignInConfirmPasswordState( + username: String = "testuser", + signInMethod: SignInMethod = SignInMethod.Username, + onSubmit: suspend (String) -> Unit = { }, + onMoveTo: (AuthenticatorInitialStep) -> Unit = { } +) = SignInConfirmPasswordStateImpl( + username = username, + signInMethod = signInMethod, + onSubmit = onSubmit, + onMoveTo = onMoveTo +) + +internal fun mockSignInSelectAuthFactorState( + username: String = "testuser", + signInMethod: SignInMethod = SignInMethod.Username, + availableAuthFactors: Set = + setOf(AuthFactor.Password(), AuthFactor.SmsOtp, AuthFactor.EmailOtp, AuthFactor.WebAuthn), + onSelect: suspend (AuthFactor) -> Unit = { }, + onMoveTo: (AuthenticatorInitialStep) -> Unit = { } +) = SignInSelectAuthFactorStateImpl( + username = username, + signInMethod = signInMethod, + availableAuthFactors = availableAuthFactors, + onSubmit = onSelect, + onMoveTo = onMoveTo +) diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPromptTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPromptTest.kt index 53d88e2d..38001881 100644 --- a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPromptTest.kt +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPromptTest.kt @@ -71,4 +71,4 @@ class PasskeyCreationPromptTest : AuthenticatorUiTest() { PasskeyPrompt(state = mockPasskeyCreationPromptState()) } } -} \ No newline at end of file +} diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignInConfirmPasswordTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignInConfirmPasswordTest.kt new file mode 100644 index 00000000..fe1fb7f4 --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignInConfirmPasswordTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2023 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. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +package com.amplifyframework.ui.authenticator.ui + +import com.amplifyframework.ui.authenticator.auth.SignInMethod +import com.amplifyframework.ui.authenticator.testUtil.AuthenticatorUiTest +import com.amplifyframework.ui.authenticator.testUtil.mockSignInConfirmPasswordState +import com.amplifyframework.ui.authenticator.ui.robots.signInConfirmPassword +import com.amplifyframework.ui.testing.ScreenshotTest +import org.junit.Test + +class SignInConfirmPasswordTest : AuthenticatorUiTest() { + + @Test + fun `title is Enter your password`() { + setContent { + SignInConfirmPassword(state = mockSignInConfirmPasswordState()) + } + signInConfirmPassword { + hasTitle("Enter your password") + } + } + + @Test + fun `button is Sign In`() { + setContent { + SignInConfirmPassword(state = mockSignInConfirmPasswordState()) + } + signInConfirmPassword { + hasSubmitButton("Sign In") + } + } + + @Test + fun `username field is populated with username`() { + setContent { + SignInConfirmPassword(state = mockSignInConfirmPasswordState(username = "testuser")) + } + signInConfirmPassword { + hasUsername("testuser") + } + } + + @Test + fun `has back to sign in footer`() { + setContent { + SignInConfirmPassword(state = mockSignInConfirmPasswordState()) + } + signInConfirmPassword { + hasBackToSignInButton() + } + } + + @Test + @ScreenshotTest + fun `default state`() { + setContent { + SignInConfirmPassword(state = mockSignInConfirmPasswordState()) + } + } + + @Test + @ScreenshotTest + fun `ready to submit`() { + setContent { + SignInConfirmPassword(state = mockSignInConfirmPasswordState()) + } + signInConfirmPassword { + setPassword("password123") + } + } + + @Test + @ScreenshotTest + fun `ready to submit with email`() { + setContent { + SignInConfirmPassword( + state = mockSignInConfirmPasswordState(username = "test@test.com", signInMethod = SignInMethod.Email) + ) + } + signInConfirmPassword { + setPassword("password123") + } + } + + @Test + @ScreenshotTest + fun `ready to submit with phonenumber`() { + setContent { + SignInConfirmPassword( + state = mockSignInConfirmPasswordState( + username = "+19021231234", + signInMethod = SignInMethod.PhoneNumber + ) + ) + } + signInConfirmPassword { + setPassword("password123") + } + } + + @Test + @ScreenshotTest + fun `password visible`() { + setContent { + SignInConfirmPassword(state = mockSignInConfirmPasswordState()) + } + signInConfirmPassword { + setPassword("password123") + clickShowPassword() + } + } +} diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactorTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactorTest.kt new file mode 100644 index 00000000..d38a9df3 --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactorTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright 2023 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. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +package com.amplifyframework.ui.authenticator.ui + +import com.amplifyframework.ui.authenticator.auth.SignInMethod +import com.amplifyframework.ui.authenticator.enums.AuthFactor +import com.amplifyframework.ui.authenticator.testUtil.AuthenticatorUiTest +import com.amplifyframework.ui.authenticator.testUtil.mockSignInSelectAuthFactorState +import com.amplifyframework.ui.authenticator.ui.robots.signInSelectAuthFactor +import com.amplifyframework.ui.testing.ScreenshotTest +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +class SignInSelectAuthFactorTest : AuthenticatorUiTest() { + + @Test + fun `title is Choose how to sign in`() { + setContent { + SignInSelectAuthFactor(state = mockSignInSelectAuthFactorState()) + } + signInSelectAuthFactor { + hasTitle("Choose how to sign in") + } + } + + @Test + fun `username field is populated`() { + setContent { + SignInSelectAuthFactor(state = mockSignInSelectAuthFactorState(username = "testuser")) + } + signInSelectAuthFactor { + hasUsername("testuser") + } + } + + @Test + fun `shows password button when available`() { + setContent { + SignInSelectAuthFactor( + state = mockSignInSelectAuthFactorState(availableAuthFactors = setOf(AuthFactor.Password())) + ) + } + signInSelectAuthFactor { + hasPasswordButton() + } + } + + @Test + fun `shows passkey button when available`() { + setContent { + SignInSelectAuthFactor( + state = mockSignInSelectAuthFactorState( + availableAuthFactors = setOf(AuthFactor.WebAuthn) + ) + ) + } + signInSelectAuthFactor { + hasPasskeyButton() + } + } + + @Test + fun `shows email button when available`() { + setContent { + SignInSelectAuthFactor( + state = mockSignInSelectAuthFactorState( + availableAuthFactors = setOf(AuthFactor.EmailOtp) + ) + ) + } + signInSelectAuthFactor { + hasEmailButton() + } + } + + @Test + fun `shows sms button when available`() { + setContent { + SignInSelectAuthFactor( + state = mockSignInSelectAuthFactorState( + availableAuthFactors = setOf(AuthFactor.SmsOtp) + ) + ) + } + signInSelectAuthFactor { + hasSmsButton() + } + } + + @Test + fun `selects password`() { + val onSelect = mockk<(AuthFactor) -> Unit>(relaxed = true) + setContent { + SignInSelectAuthFactor(state = mockSignInSelectAuthFactorState(onSelect = onSelect)) + } + signInSelectAuthFactor { + clickOnAuthFactor(AuthFactor.Password()) + } + verify { + onSelect(withArg { it.shouldBeInstanceOf() }) + } + } + + @Test + fun `selects sms otp`() { + val onSelect = mockk<(AuthFactor) -> Unit>(relaxed = true) + setContent { + SignInSelectAuthFactor(state = mockSignInSelectAuthFactorState(onSelect = onSelect)) + } + signInSelectAuthFactor { + clickOnAuthFactor(AuthFactor.SmsOtp) + } + verify { + onSelect(AuthFactor.SmsOtp) + } + } + + @Test + fun `selects email otp`() { + val onSelect = mockk<(AuthFactor) -> Unit>(relaxed = true) + setContent { + SignInSelectAuthFactor(state = mockSignInSelectAuthFactorState(onSelect = onSelect)) + } + signInSelectAuthFactor { + clickOnAuthFactor(AuthFactor.EmailOtp) + } + verify { + onSelect(AuthFactor.EmailOtp) + } + } + + @Test + fun `selects passkey`() { + val onSelect = mockk<(AuthFactor) -> Unit>(relaxed = true) + setContent { + SignInSelectAuthFactor(state = mockSignInSelectAuthFactorState(onSelect = onSelect)) + } + signInSelectAuthFactor { + clickOnAuthFactor(AuthFactor.WebAuthn) + } + verify { + onSelect(AuthFactor.WebAuthn) + } + } + + @Test + @ScreenshotTest + fun `default state with all factors`() { + setContent { + SignInSelectAuthFactor(state = mockSignInSelectAuthFactorState()) + } + } + + @Test + @ScreenshotTest + fun `default state with all factors with phone number`() { + setContent { + SignInSelectAuthFactor( + state = mockSignInSelectAuthFactorState( + username = "+19021234567", + signInMethod = SignInMethod.PhoneNumber + ) + ) + } + } + + @Test + @ScreenshotTest + fun `default state with all factors with email`() { + setContent { + SignInSelectAuthFactor( + state = mockSignInSelectAuthFactorState( + username = "test@test.com", + signInMethod = SignInMethod.Email + ) + ) + } + } + + @Test + @ScreenshotTest + fun `no password`() { + setContent { + SignInSelectAuthFactor( + state = mockSignInSelectAuthFactorState( + availableAuthFactors = setOf(AuthFactor.EmailOtp, AuthFactor.SmsOtp) + ) + ) + } + } +} diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/SignInConfirmPasswordRobot.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/SignInConfirmPasswordRobot.kt new file mode 100644 index 00000000..fec97e7f --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/SignInConfirmPasswordRobot.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2023 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. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +package com.amplifyframework.ui.authenticator.ui.robots + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import com.amplifyframework.ui.authenticator.forms.FieldKey +import com.amplifyframework.ui.authenticator.ui.TestTags +import com.amplifyframework.ui.authenticator.ui.testTag +import com.amplifyframework.ui.testing.ComposeTest + +fun ComposeTest.signInConfirmPassword(func: SignInConfirmPasswordRobot.() -> Unit) = + SignInConfirmPasswordRobot(composeTestRule).func() + +class SignInConfirmPasswordRobot(rule: ComposeTestRule) : ScreenLevelRobot(rule) { + fun hasSubmitButton(expected: String) = assertExists(TestTags.SignInButton, expected) + fun hasUsername(expected: String) = composeTestRule.onNode( + hasTestTag(FieldKey.Username.testTag) and hasText(expected) + ).assertExists() + fun hasBackToSignInButton() = composeTestRule.onNode(hasTestTag(TestTags.BackToSignInButton)).assertExists() + + fun setPassword(value: String) = setFieldContent(FieldKey.Password, value) + fun clickShowPassword() = clickOnShowIcon(FieldKey.Password) + fun clickSubmitButton() = clickOnTag(TestTags.SignInButton) + fun clickBackToSignIn() = clickOnTag(TestTags.BackToSignInButton) +} diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/SignInSelectAuthFactorRobot.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/SignInSelectAuthFactorRobot.kt new file mode 100644 index 00000000..143851b4 --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/SignInSelectAuthFactorRobot.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2023 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. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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. + */ + +package com.amplifyframework.ui.authenticator.ui.robots + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import com.amplifyframework.ui.authenticator.enums.AuthFactor +import com.amplifyframework.ui.authenticator.forms.FieldKey +import com.amplifyframework.ui.authenticator.ui.TestTags +import com.amplifyframework.ui.authenticator.ui.testTag +import com.amplifyframework.ui.testing.ComposeTest + +fun ComposeTest.signInSelectAuthFactor(func: SignInSelectAuthFactorRobot.() -> Unit) = + SignInSelectAuthFactorRobot(composeTestRule).func() + +class SignInSelectAuthFactorRobot(rule: ComposeTestRule) : ScreenLevelRobot(rule) { + fun hasUsername(expected: String) = composeTestRule.onNode( + hasTestTag(FieldKey.Username.testTag) and hasText(expected) + ).assertExists() + + fun hasPasswordButton() = composeTestRule.onNode(hasTestTag(TestTags.AuthFactorPassword)).assertExists() + fun hasPasskeyButton() = composeTestRule.onNode(hasTestTag(TestTags.AuthFactorPasskey)).assertExists() + fun hasEmailButton() = composeTestRule.onNode(hasTestTag(TestTags.AuthFactorEmail)).assertExists() + fun hasSmsButton() = composeTestRule.onNode(hasTestTag(TestTags.AuthFactorSms)).assertExists() + + fun clickOnAuthFactor(factor: AuthFactor) = clickOnTag(factor.testTag) +} diff --git a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_default-state.png b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_default-state.png new file mode 100644 index 00000000..f07df71c Binary files /dev/null and b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_default-state.png differ diff --git a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_password-visible.png b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_password-visible.png new file mode 100644 index 00000000..104df595 Binary files /dev/null and b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_password-visible.png differ diff --git a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-email.png b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-email.png new file mode 100644 index 00000000..2a5102a8 Binary files /dev/null and b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-email.png differ diff --git a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-phonenumber.png b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-phonenumber.png new file mode 100644 index 00000000..2211947f Binary files /dev/null and b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-phonenumber.png differ diff --git a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit.png b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit.png new file mode 100644 index 00000000..ec72099f Binary files /dev/null and b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit.png differ diff --git a/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-email.png b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-email.png new file mode 100644 index 00000000..02d52420 Binary files /dev/null and b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-email.png differ diff --git a/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-phone-number.png b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-phone-number.png new file mode 100644 index 00000000..4093b162 Binary files /dev/null and b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-phone-number.png differ diff --git a/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors.png b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors.png new file mode 100644 index 00000000..dcda3324 Binary files /dev/null and b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors.png differ diff --git a/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_no-password.png b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_no-password.png new file mode 100644 index 00000000..73844633 Binary files /dev/null and b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_no-password.png differ