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 483dc409..d8270d89 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt @@ -23,6 +23,7 @@ import com.amplifyframework.auth.AuthUser 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.AuthenticatorInitialStep import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep import com.amplifyframework.ui.authenticator.forms.MutableFormState @@ -460,3 +461,36 @@ interface VerifyUserConfirmState : AuthenticatorStepState { */ fun skip() } + +/** + * The user is being shown a prompt to create a passkey, encouraging them to use this as a way to sign in quickly + * via biometrics + */ +@Stable +interface PasskeyCreationPromptState : AuthenticatorStepState { + /** + * Create a passkey + */ + suspend fun createPasskey() + + /** + * Skip passkey creation and continue to the next step + */ + suspend fun skip() +} + +/** + * The user is being shown a confirmation screen after creating a passkey + */ +@Stable +interface PasskeyCreatedState : AuthenticatorStepState { + /** + * A list of existing passkeys for this user, including the one they've just created + */ + val passkeys: List + + /** + * Continue to the next step + */ + suspend fun done() +} 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 700efecf..e6379b75 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 @@ -120,4 +120,15 @@ abstract class AuthenticatorStep internal constructor() { * The user has initiated verification of an account recovery mechanism (email, phone) and needs to provide a confirmation code. */ object VerifyUserConfirm : AuthenticatorStep() + + /** + * The user is being shown a prompt to create a passkey, encouraging them to use this as a way to sign in quickly + * via biometrics + */ + object PasskeyCreationPrompt : AuthenticatorStep() + + /** + * The user is being shown a confirmation screen after creating a passkey + */ + object PasskeyCreated : AuthenticatorStep() } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreatedStateImpl.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreatedStateImpl.kt new file mode 100644 index 00000000..e7df13e2 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreatedStateImpl.kt @@ -0,0 +1,14 @@ +package com.amplifyframework.ui.authenticator.states + +import com.amplifyframework.auth.result.AuthWebAuthnCredential +import com.amplifyframework.ui.authenticator.PasskeyCreatedState +import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep + +internal class PasskeyCreatedStateImpl( + override val passkeys: List, + private val onDone: suspend () -> Unit +) : PasskeyCreatedState { + override val step: AuthenticatorStep = AuthenticatorStep.PasskeyCreated + + override suspend fun done() = onDone() +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreationPromptStateImpl.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreationPromptStateImpl.kt new file mode 100644 index 00000000..43bc4ff8 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreationPromptStateImpl.kt @@ -0,0 +1,21 @@ +package com.amplifyframework.ui.authenticator.states + +import com.amplifyframework.ui.authenticator.PasskeyCreationPromptState +import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class PasskeyCreationPromptStateImpl(private val onSubmit: suspend () -> Unit, private val onSkip: suspend () -> Unit) : + PasskeyCreationPromptState { + private val mutex = Mutex() + + override suspend fun createPasskey() { + mutex.withLock { + onSubmit() + } + } + + override suspend fun skip() = onSkip() + + override val step = AuthenticatorStep.PasskeyCreationPrompt +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorButton.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorButton.kt new file mode 100644 index 00000000..0eac5752 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorButton.kt @@ -0,0 +1,60 @@ +package com.amplifyframework.ui.authenticator.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.amplifyframework.ui.authenticator.R + +internal enum class ButtonStyle { + Primary, + Secondary +} + +/** + * The button displayed in Authenticator. + * @param onClick The click handler for the button + * @param loading True to show the [loadingIndicator] content, false to show the button label. + * @param modifier The [Modifier] for the composable. + * @param label The label for the button + * @param loadingIndicator The content to show when loading. + */ +@Composable +internal fun AuthenticatorButton( + onClick: () -> Unit, + loading: Boolean, + modifier: Modifier = Modifier, + label: String = stringResource(R.string.amplify_ui_authenticator_button_submit), + loadingIndicator: @Composable () -> Unit = { LoadingIndicator() }, + enabled: Boolean = true, + style: ButtonStyle = ButtonStyle.Primary +) { + if (style == ButtonStyle.Primary) { + Button( + modifier = modifier.fillMaxWidth(), + onClick = onClick, + enabled = enabled && !loading + ) { + if (loading) { + loadingIndicator() + } else { + Text(label) + } + } + } else { + OutlinedButton( + modifier = modifier.fillMaxWidth(), + onClick = onClick, + enabled = enabled && !loading + ) { + if (loading) { + loadingIndicator() + } else { + Text(label) + } + } + } +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorForm.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorForm.kt index 57b31c6a..37c26d46 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorForm.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorForm.kt @@ -20,15 +20,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment 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.forms.MutableFormState /** @@ -37,10 +33,7 @@ import com.amplifyframework.ui.authenticator.forms.MutableFormState * @param modifier The Modifier for the composable. */ @Composable -internal fun AuthenticatorForm( - state: MutableFormState, - modifier: Modifier = Modifier -) { +internal fun AuthenticatorForm(state: MutableFormState, modifier: Modifier = Modifier) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, @@ -57,32 +50,3 @@ internal fun AuthenticatorForm( Spacer(modifier = Modifier.size(16.dp)) } } - -/** - * The button displayed in Authenticator. - * @param onClick The click handler for the button - * @param loading True to show the [loadingIndicator] content, false to show the button label. - * @param modifier The [Modifier] for the composable. - * @param label The label for the button - * @param loadingIndicator The content to show when loading. - */ -@Composable -internal fun AuthenticatorButton( - onClick: () -> Unit, - loading: Boolean, - modifier: Modifier = Modifier, - label: String = stringResource(R.string.amplify_ui_authenticator_button_submit), - loadingIndicator: @Composable () -> Unit = { LoadingIndicator() } -) { - Button( - modifier = modifier.fillMaxWidth(), - onClick = onClick, - enabled = !loading - ) { - if (loading) { - loadingIndicator() - } else { - Text(label) - } - } -} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreated.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreated.kt new file mode 100644 index 00000000..349f4409 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreated.kt @@ -0,0 +1,95 @@ +package com.amplifyframework.ui.authenticator.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +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.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.amplifyframework.auth.result.AuthWebAuthnCredential +import com.amplifyframework.ui.authenticator.PasskeyCreatedState +import com.amplifyframework.ui.authenticator.R +import kotlinx.coroutines.launch + +@Composable +fun PasskeyCreated( + state: PasskeyCreatedState, + modifier: Modifier = Modifier, + headerContent: @Composable (PasskeyCreatedState) -> Unit = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Image( + painter = painterResource(R.drawable.authenticator_success), + contentDescription = null, + modifier = Modifier.size(32.dp) + ) + AuthenticatorTitle(stringResource(R.string.amplify_ui_authenticator_title_passkey_created)) + } + }, + footerContent: @Composable (PasskeyCreatedState) -> Unit = { } +) { + val scope = rememberCoroutineScope() + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + headerContent(state) + + if (state.passkeys.isNotEmpty()) { + Text( + stringResource(R.string.amplify_ui_authenticator_existing_passkeys), + style = MaterialTheme.typography.titleSmall + ) + Spacer(modifier = Modifier.size(8.dp)) + Card { + Column(modifier = Modifier.padding(16.dp).fillMaxWidth()) { + state.passkeys.forEachIndexed { index, passkey -> + Passkey(passkey) + if (index != state.passkeys.size - 1) { + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + } + } + } + } + Spacer(modifier = Modifier.size(16.dp)) + } + + var enabled by remember { mutableStateOf(true) } + AuthenticatorButton( + onClick = { + scope.launch { + enabled = false + state.done() + enabled = true + } + }, + loading = !enabled, + label = stringResource(R.string.amplify_ui_authenticator_button_continue), + modifier = Modifier.testTag(TestTags.ContinueButton) + ) + + footerContent(state) + } +} + +@Composable +private fun Passkey(credential: AuthWebAuthnCredential) { + Text(credential.friendlyName ?: "Unknown Passkey") // todo String resource +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPrompt.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPrompt.kt new file mode 100644 index 00000000..bf4a23a9 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPrompt.kt @@ -0,0 +1,93 @@ +package com.amplifyframework.ui.authenticator.ui + +import androidx.compose.foundation.Image +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.amplifyframework.ui.authenticator.PasskeyCreationPromptState +import com.amplifyframework.ui.authenticator.R +import kotlinx.coroutines.launch + +private enum class Action { + CreatingPasskey, + Skipping +} + +@Composable +fun PasskeyPrompt( + state: PasskeyCreationPromptState, + modifier: Modifier = Modifier, + headerContent: @Composable (PasskeyCreationPromptState) -> Unit = { + AuthenticatorTitle(stringResource(R.string.amplify_ui_authenticator_title_prompt_for_passkey)) + }, + footerContent: @Composable (PasskeyCreationPromptState) -> Unit = {} +) { + val scope = rememberCoroutineScope() + + var inProgress by remember { mutableStateOf(null) } + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + headerContent(state) + + Text(stringResource(R.string.amplify_ui_authenticator_passkey_prompt_content)) + + Spacer(modifier = Modifier.size(16.dp)) + + Image( + painter = painterResource(R.drawable.authenticator_passkey), + contentDescription = null, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.size(16.dp)) + + AuthenticatorButton( + onClick = { + scope.launch { + inProgress = Action.CreatingPasskey + state.createPasskey() + inProgress = null + } + }, + loading = inProgress == Action.CreatingPasskey, + enabled = inProgress == null, + label = stringResource(R.string.amplify_ui_authenticator_button_create_passkey), + modifier = Modifier.testTag(TestTags.CreatePasskeyButton) + ) + + AuthenticatorButton( + modifier = Modifier.fillMaxWidth().testTag(TestTags.SkipPasskeyButton), + onClick = { + scope.launch { + inProgress = Action.Skipping + state.skip() + inProgress = null + } + }, + loading = inProgress == Action.Skipping, + enabled = inProgress == null, + label = stringResource(R.string.amplify_ui_authenticator_button_skip_passkey), + style = ButtonStyle.Secondary + ) + + footerContent(state) + } +} 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 a63665a5..a5e00e11 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 @@ -27,6 +27,9 @@ internal object TestTags { const val ForgotPasswordButton = "ForgotPasswordButton" const val CreateAccountButton = "CreateAccountButton" const val PasswordResetButton = "PasswordResetButton" + const val ContinueButton = "ContinueButton" + const val CreatePasskeyButton = "CreatePasskeyButton" + const val SkipPasskeyButton = "SkipPasskeyButton" const val AuthenticatorTitle = "AuthenticatorTitle" const val ShowPasswordIcon = "ShowPasswordIcon" diff --git a/authenticator/src/main/res/drawable/authenticator_passkey.xml b/authenticator/src/main/res/drawable/authenticator_passkey.xml new file mode 100644 index 00000000..91c21dee --- /dev/null +++ b/authenticator/src/main/res/drawable/authenticator_passkey.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/authenticator/src/main/res/drawable/authenticator_success.xml b/authenticator/src/main/res/drawable/authenticator_success.xml new file mode 100644 index 00000000..9a86c70a --- /dev/null +++ b/authenticator/src/main/res/drawable/authenticator_success.xml @@ -0,0 +1,9 @@ + + + diff --git a/authenticator/src/main/res/values/buttons.xml b/authenticator/src/main/res/values/buttons.xml index 2e654d2d..4e6a6cab 100644 --- a/authenticator/src/main/res/values/buttons.xml +++ b/authenticator/src/main/res/values/buttons.xml @@ -26,4 +26,6 @@ Send Code Skip Copy Key + 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 755ec635..07db7eed 100644 --- a/authenticator/src/main/res/values/strings.xml +++ b/authenticator/src/main/res/values/strings.xml @@ -36,4 +36,10 @@ Text Message (SMS) Authenticator App (TOTP) Email Message + + + Passkeys are WebAuthn credentials that validate your identity using biometric data like touch or facial recognition or device authentication like passwords or PINs, serving as a secure password replacement. + + + Existing Passkeys diff --git a/authenticator/src/main/res/values/titles.xml b/authenticator/src/main/res/values/titles.xml index e3db56de..213f47f1 100644 --- a/authenticator/src/main/res/values/titles.xml +++ b/authenticator/src/main/res/values/titles.xml @@ -27,4 +27,6 @@ Choose your preferred two-factor authentication method to set up Choose your two-factor authentication method Add Email for Two-Factor Authentication + Sign in faster with Passkey + Passkey created successfully! 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 b37831f5..f5e812d4 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 @@ -18,11 +18,14 @@ package com.amplifyframework.ui.authenticator.testUtil import com.amplifyframework.auth.AuthCodeDeliveryDetails import com.amplifyframework.auth.AuthUserAttributeKey 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.AuthenticatorInitialStep import com.amplifyframework.ui.authenticator.forms.FormData import com.amplifyframework.ui.authenticator.mockAuthCodeDeliveryDetails +import com.amplifyframework.ui.authenticator.states.PasskeyCreatedStateImpl +import com.amplifyframework.ui.authenticator.states.PasskeyCreationPromptStateImpl import com.amplifyframework.ui.authenticator.states.PasswordResetConfirmStateImpl import com.amplifyframework.ui.authenticator.states.PasswordResetStateImpl import com.amplifyframework.ui.authenticator.states.SignInConfirmMfaStateImpl @@ -105,13 +108,12 @@ internal fun mockSignInContinueWithTotpSetupState( onMoveTo = onMoveTo ) -internal fun mockSignInConfirmMfaState( - deliveryDetails: AuthCodeDeliveryDetails = mockAuthCodeDeliveryDetails() -) = SignInConfirmMfaStateImpl( - deliveryDetails = deliveryDetails, - onSubmit = { }, - onMoveTo = { } -) +internal fun mockSignInConfirmMfaState(deliveryDetails: AuthCodeDeliveryDetails = mockAuthCodeDeliveryDetails()) = + SignInConfirmMfaStateImpl( + deliveryDetails = deliveryDetails, + onSubmit = { }, + onMoveTo = { } + ) internal fun mockSignInContinueWithMfaSetupSelectionState( allowedMfaTypes: Set = setOf(MFAType.TOTP, MFAType.SMS, MFAType.EMAIL) @@ -120,3 +122,17 @@ internal fun mockSignInContinueWithMfaSetupSelectionState( onSubmit = { }, onMoveTo = { } ) + +internal fun mockPasskeyCreatedState( + passkeys: List = emptyList(), + onDone: suspend () -> Unit = {} +) = PasskeyCreatedStateImpl( + passkeys = passkeys, + onDone = onDone +) + +internal fun mockPasskeyCreationPromptState(onSubmit: suspend () -> Unit = {}, onSkip: suspend () -> Unit = {}) = + PasskeyCreationPromptStateImpl( + onSubmit = onSubmit, + onSkip = onSkip + ) diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreatedTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreatedTest.kt new file mode 100644 index 00000000..bd91a9b9 --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreatedTest.kt @@ -0,0 +1,83 @@ +package com.amplifyframework.ui.authenticator.ui + +import com.amplifyframework.auth.result.AuthWebAuthnCredential +import com.amplifyframework.ui.authenticator.testUtil.AuthenticatorUiTest +import com.amplifyframework.ui.authenticator.testUtil.mockPasskeyCreatedState +import com.amplifyframework.ui.authenticator.ui.robots.passkeyCreated +import com.amplifyframework.ui.testing.ScreenshotTest +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import org.junit.Test + +class PasskeyCreatedTest : AuthenticatorUiTest() { + + @Test + fun `title is Passkey created successfully`() { + setContent { + PasskeyCreated(state = mockPasskeyCreatedState()) + } + passkeyCreated { + hasTitle("Passkey created successfully!") + } + } + + @Test + fun `button is Continue`() { + setContent { + PasskeyCreated(state = mockPasskeyCreatedState()) + } + passkeyCreated { + hasContinueButton("Continue") + } + } + + @Test + fun `clicking continue calls done`() { + val onDone = mockk Unit>(relaxed = true) + setContent { + PasskeyCreated(state = mockPasskeyCreatedState(onDone = onDone)) + } + passkeyCreated { + clickContinueButton() + } + coVerify { onDone() } + } + + @Test + fun `displays existing passkeys when present`() { + val passkey = mockk { + every { friendlyName } returns "Test Passkey" + } + setContent { + PasskeyCreated(state = mockPasskeyCreatedState(passkeys = listOf(passkey))) + } + passkeyCreated { + hasPasskeyText("Existing Passkeys") + hasPasskeyText("Test Passkey") + } + } + + @Test + @ScreenshotTest + fun `with one passkey`() { + val passkey = mockk { + every { friendlyName } returns "Test Passkey" + } + setContent { + PasskeyCreated(state = mockPasskeyCreatedState(passkeys = listOf(passkey))) + } + } + + @Test + @ScreenshotTest + fun `with multiple passkeys`() { + val passkeys = listOf( + mockk { every { friendlyName } returns "Test Passkey 1" }, + mockk { every { friendlyName } returns "Test Passkey 2" } + ) + setContent { + PasskeyCreated(state = mockPasskeyCreatedState(passkeys = passkeys)) + } + } +} 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 new file mode 100644 index 00000000..53d88e2d --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPromptTest.kt @@ -0,0 +1,74 @@ +package com.amplifyframework.ui.authenticator.ui + +import com.amplifyframework.ui.authenticator.testUtil.AuthenticatorUiTest +import com.amplifyframework.ui.authenticator.testUtil.mockPasskeyCreationPromptState +import com.amplifyframework.ui.authenticator.ui.robots.passkeyCreationPrompt +import com.amplifyframework.ui.testing.ScreenshotTest +import io.mockk.coVerify +import io.mockk.mockk +import org.junit.Test + +class PasskeyCreationPromptTest : AuthenticatorUiTest() { + + @Test + fun `title is Sign in faster with Passkey`() { + setContent { + PasskeyPrompt(state = mockPasskeyCreationPromptState()) + } + passkeyCreationPrompt { + hasTitle("Sign in faster with Passkey") + } + } + + @Test + fun `has create passkey button`() { + setContent { + PasskeyPrompt(state = mockPasskeyCreationPromptState()) + } + passkeyCreationPrompt { + hasCreatePasskeyButton("Create a Passkey") + } + } + + @Test + fun `has skip passkey button`() { + setContent { + PasskeyPrompt(state = mockPasskeyCreationPromptState()) + } + passkeyCreationPrompt { + hasSkipPasskeyButton("Continue without a Passkey") + } + } + + @Test + fun `clicking create passkey calls createPasskey`() { + val onSubmit = mockk Unit>(relaxed = true) + setContent { + PasskeyPrompt(state = mockPasskeyCreationPromptState(onSubmit = onSubmit)) + } + passkeyCreationPrompt { + clickCreatePasskeyButton() + } + coVerify { onSubmit() } + } + + @Test + fun `clicking skip calls skip`() { + val onSkip = mockk Unit>(relaxed = true) + setContent { + PasskeyPrompt(state = mockPasskeyCreationPromptState(onSkip = onSkip)) + } + passkeyCreationPrompt { + clickSkipPasskeyButton() + } + coVerify { onSkip() } + } + + @Test + @ScreenshotTest + fun `default state`() { + setContent { + PasskeyPrompt(state = mockPasskeyCreationPromptState()) + } + } +} \ No newline at end of file diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/PasskeyCreatedRobot.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/PasskeyCreatedRobot.kt new file mode 100644 index 00000000..7c3985de --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/PasskeyCreatedRobot.kt @@ -0,0 +1,14 @@ +package com.amplifyframework.ui.authenticator.ui.robots + +import androidx.compose.ui.test.junit4.ComposeTestRule +import com.amplifyframework.ui.authenticator.ui.TestTags +import com.amplifyframework.ui.testing.ComposeTest + +fun ComposeTest.passkeyCreated(func: PasskeyCreatedRobot.() -> Unit) = PasskeyCreatedRobot(composeTestRule).func() + +class PasskeyCreatedRobot(rule: ComposeTestRule) : ScreenLevelRobot(rule) { + fun hasContinueButton(expected: String) = assertExists(TestTags.ContinueButton, expected) + fun hasPasskeyText(text: String) = assertExists(text) + + fun clickContinueButton() = clickOnTag(TestTags.ContinueButton) +} diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/PasskeyCreationPromptRobot.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/PasskeyCreationPromptRobot.kt new file mode 100644 index 00000000..8562606c --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/PasskeyCreationPromptRobot.kt @@ -0,0 +1,16 @@ +package com.amplifyframework.ui.authenticator.ui.robots + +import androidx.compose.ui.test.junit4.ComposeTestRule +import com.amplifyframework.ui.authenticator.ui.TestTags +import com.amplifyframework.ui.testing.ComposeTest + +fun ComposeTest.passkeyCreationPrompt(func: PasskeyCreationPromptRobot.() -> Unit) = + PasskeyCreationPromptRobot(composeTestRule).func() + +class PasskeyCreationPromptRobot(rule: ComposeTestRule) : ScreenLevelRobot(rule) { + fun hasCreatePasskeyButton(expected: String) = assertExists(TestTags.CreatePasskeyButton, expected) + fun hasSkipPasskeyButton(expected: String) = assertExists(TestTags.SkipPasskeyButton, expected) + fun clickCreatePasskeyButton() = clickOnTag(TestTags.CreatePasskeyButton) + fun clickSkipPasskeyButton() = clickOnTag(TestTags.SkipPasskeyButton) + fun hasPromptText(text: String) = assertExists(text) +} diff --git a/authenticator/src/test/screenshots/PasskeyCreatedTest_with-multiple-passkeys.png b/authenticator/src/test/screenshots/PasskeyCreatedTest_with-multiple-passkeys.png new file mode 100644 index 00000000..f13ce085 Binary files /dev/null and b/authenticator/src/test/screenshots/PasskeyCreatedTest_with-multiple-passkeys.png differ diff --git a/authenticator/src/test/screenshots/PasskeyCreatedTest_with-one-passkey.png b/authenticator/src/test/screenshots/PasskeyCreatedTest_with-one-passkey.png new file mode 100644 index 00000000..9f04397e Binary files /dev/null and b/authenticator/src/test/screenshots/PasskeyCreatedTest_with-one-passkey.png differ diff --git a/authenticator/src/test/screenshots/PasskeyCreationPromptTest_default-state.png b/authenticator/src/test/screenshots/PasskeyCreationPromptTest_default-state.png new file mode 100644 index 00000000..8ab6bbb6 Binary files /dev/null and b/authenticator/src/test/screenshots/PasskeyCreationPromptTest_default-state.png differ