Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<AuthWebAuthnCredential>

/**
* Continue to the next step
*/
suspend fun done()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
@@ -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<AuthWebAuthnCredential>,
private val onDone: suspend () -> Unit
) : PasskeyCreatedState {
override val step: AuthenticatorStep = AuthenticatorStep.PasskeyCreated

override suspend fun done() = onDone()
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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,
Expand All @@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading