Skip to content

Commit dddceaa

Browse files
authored
feat(authenticator): Add passkey prompt UI (#260)
1 parent 2dcec64 commit dddceaa

22 files changed

+643
-44
lines changed

authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import com.amplifyframework.auth.AuthUser
2323
import com.amplifyframework.auth.AuthUserAttribute
2424
import com.amplifyframework.auth.MFAType
2525
import com.amplifyframework.auth.result.AuthSignOutResult
26+
import com.amplifyframework.auth.result.AuthWebAuthnCredential
2627
import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep
2728
import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep
2829
import com.amplifyframework.ui.authenticator.forms.MutableFormState
@@ -460,3 +461,36 @@ interface VerifyUserConfirmState : AuthenticatorStepState {
460461
*/
461462
fun skip()
462463
}
464+
465+
/**
466+
* The user is being shown a prompt to create a passkey, encouraging them to use this as a way to sign in quickly
467+
* via biometrics
468+
*/
469+
@Stable
470+
interface PasskeyCreationPromptState : AuthenticatorStepState {
471+
/**
472+
* Create a passkey
473+
*/
474+
suspend fun createPasskey()
475+
476+
/**
477+
* Skip passkey creation and continue to the next step
478+
*/
479+
suspend fun skip()
480+
}
481+
482+
/**
483+
* The user is being shown a confirmation screen after creating a passkey
484+
*/
485+
@Stable
486+
interface PasskeyCreatedState : AuthenticatorStepState {
487+
/**
488+
* A list of existing passkeys for this user, including the one they've just created
489+
*/
490+
val passkeys: List<AuthWebAuthnCredential>
491+
492+
/**
493+
* Continue to the next step
494+
*/
495+
suspend fun done()
496+
}

authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/AuthenticatorStep.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,15 @@ abstract class AuthenticatorStep internal constructor() {
120120
* The user has initiated verification of an account recovery mechanism (email, phone) and needs to provide a confirmation code.
121121
*/
122122
object VerifyUserConfirm : AuthenticatorStep()
123+
124+
/**
125+
* The user is being shown a prompt to create a passkey, encouraging them to use this as a way to sign in quickly
126+
* via biometrics
127+
*/
128+
object PasskeyCreationPrompt : AuthenticatorStep()
129+
130+
/**
131+
* The user is being shown a confirmation screen after creating a passkey
132+
*/
133+
object PasskeyCreated : AuthenticatorStep()
123134
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.amplifyframework.ui.authenticator.states
2+
3+
import com.amplifyframework.auth.result.AuthWebAuthnCredential
4+
import com.amplifyframework.ui.authenticator.PasskeyCreatedState
5+
import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep
6+
7+
internal class PasskeyCreatedStateImpl(
8+
override val passkeys: List<AuthWebAuthnCredential>,
9+
private val onDone: suspend () -> Unit
10+
) : PasskeyCreatedState {
11+
override val step: AuthenticatorStep = AuthenticatorStep.PasskeyCreated
12+
13+
override suspend fun done() = onDone()
14+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.amplifyframework.ui.authenticator.states
2+
3+
import com.amplifyframework.ui.authenticator.PasskeyCreationPromptState
4+
import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep
5+
import kotlinx.coroutines.sync.Mutex
6+
import kotlinx.coroutines.sync.withLock
7+
8+
class PasskeyCreationPromptStateImpl(private val onSubmit: suspend () -> Unit, private val onSkip: suspend () -> Unit) :
9+
PasskeyCreationPromptState {
10+
private val mutex = Mutex()
11+
12+
override suspend fun createPasskey() {
13+
mutex.withLock {
14+
onSubmit()
15+
}
16+
}
17+
18+
override suspend fun skip() = onSkip()
19+
20+
override val step = AuthenticatorStep.PasskeyCreationPrompt
21+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.amplifyframework.ui.authenticator.ui
2+
3+
import androidx.compose.foundation.layout.fillMaxWidth
4+
import androidx.compose.material3.Button
5+
import androidx.compose.material3.OutlinedButton
6+
import androidx.compose.material3.Text
7+
import androidx.compose.runtime.Composable
8+
import androidx.compose.ui.Modifier
9+
import androidx.compose.ui.res.stringResource
10+
import com.amplifyframework.ui.authenticator.R
11+
12+
internal enum class ButtonStyle {
13+
Primary,
14+
Secondary
15+
}
16+
17+
/**
18+
* The button displayed in Authenticator.
19+
* @param onClick The click handler for the button
20+
* @param loading True to show the [loadingIndicator] content, false to show the button label.
21+
* @param modifier The [Modifier] for the composable.
22+
* @param label The label for the button
23+
* @param loadingIndicator The content to show when loading.
24+
*/
25+
@Composable
26+
internal fun AuthenticatorButton(
27+
onClick: () -> Unit,
28+
loading: Boolean,
29+
modifier: Modifier = Modifier,
30+
label: String = stringResource(R.string.amplify_ui_authenticator_button_submit),
31+
loadingIndicator: @Composable () -> Unit = { LoadingIndicator() },
32+
enabled: Boolean = true,
33+
style: ButtonStyle = ButtonStyle.Primary
34+
) {
35+
if (style == ButtonStyle.Primary) {
36+
Button(
37+
modifier = modifier.fillMaxWidth(),
38+
onClick = onClick,
39+
enabled = enabled && !loading
40+
) {
41+
if (loading) {
42+
loadingIndicator()
43+
} else {
44+
Text(label)
45+
}
46+
}
47+
} else {
48+
OutlinedButton(
49+
modifier = modifier.fillMaxWidth(),
50+
onClick = onClick,
51+
enabled = enabled && !loading
52+
) {
53+
if (loading) {
54+
loadingIndicator()
55+
} else {
56+
Text(label)
57+
}
58+
}
59+
}
60+
}

authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorForm.kt

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,11 @@ import androidx.compose.foundation.layout.Column
2020
import androidx.compose.foundation.layout.Spacer
2121
import androidx.compose.foundation.layout.fillMaxWidth
2222
import androidx.compose.foundation.layout.size
23-
import androidx.compose.material3.Button
24-
import androidx.compose.material3.Text
2523
import androidx.compose.runtime.Composable
2624
import androidx.compose.ui.Alignment
2725
import androidx.compose.ui.Modifier
2826
import androidx.compose.ui.platform.testTag
29-
import androidx.compose.ui.res.stringResource
3027
import androidx.compose.ui.unit.dp
31-
import com.amplifyframework.ui.authenticator.R
3228
import com.amplifyframework.ui.authenticator.forms.MutableFormState
3329

3430
/**
@@ -37,10 +33,7 @@ import com.amplifyframework.ui.authenticator.forms.MutableFormState
3733
* @param modifier The Modifier for the composable.
3834
*/
3935
@Composable
40-
internal fun AuthenticatorForm(
41-
state: MutableFormState,
42-
modifier: Modifier = Modifier
43-
) {
36+
internal fun AuthenticatorForm(state: MutableFormState, modifier: Modifier = Modifier) {
4437
Column(
4538
modifier = modifier,
4639
horizontalAlignment = Alignment.CenterHorizontally,
@@ -57,32 +50,3 @@ internal fun AuthenticatorForm(
5750
Spacer(modifier = Modifier.size(16.dp))
5851
}
5952
}
60-
61-
/**
62-
* The button displayed in Authenticator.
63-
* @param onClick The click handler for the button
64-
* @param loading True to show the [loadingIndicator] content, false to show the button label.
65-
* @param modifier The [Modifier] for the composable.
66-
* @param label The label for the button
67-
* @param loadingIndicator The content to show when loading.
68-
*/
69-
@Composable
70-
internal fun AuthenticatorButton(
71-
onClick: () -> Unit,
72-
loading: Boolean,
73-
modifier: Modifier = Modifier,
74-
label: String = stringResource(R.string.amplify_ui_authenticator_button_submit),
75-
loadingIndicator: @Composable () -> Unit = { LoadingIndicator() }
76-
) {
77-
Button(
78-
modifier = modifier.fillMaxWidth(),
79-
onClick = onClick,
80-
enabled = !loading
81-
) {
82-
if (loading) {
83-
loadingIndicator()
84-
} else {
85-
Text(label)
86-
}
87-
}
88-
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.amplifyframework.ui.authenticator.ui
2+
3+
import androidx.compose.foundation.Image
4+
import androidx.compose.foundation.layout.Arrangement
5+
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.Spacer
7+
import androidx.compose.foundation.layout.fillMaxWidth
8+
import androidx.compose.foundation.layout.padding
9+
import androidx.compose.foundation.layout.size
10+
import androidx.compose.material3.Card
11+
import androidx.compose.material3.HorizontalDivider
12+
import androidx.compose.material3.MaterialTheme
13+
import androidx.compose.material3.Text
14+
import androidx.compose.runtime.Composable
15+
import androidx.compose.runtime.getValue
16+
import androidx.compose.runtime.mutableStateOf
17+
import androidx.compose.runtime.remember
18+
import androidx.compose.runtime.rememberCoroutineScope
19+
import androidx.compose.runtime.setValue
20+
import androidx.compose.ui.Modifier
21+
import androidx.compose.ui.platform.testTag
22+
import androidx.compose.ui.res.painterResource
23+
import androidx.compose.ui.res.stringResource
24+
import androidx.compose.ui.unit.dp
25+
import com.amplifyframework.auth.result.AuthWebAuthnCredential
26+
import com.amplifyframework.ui.authenticator.PasskeyCreatedState
27+
import com.amplifyframework.ui.authenticator.R
28+
import kotlinx.coroutines.launch
29+
30+
@Composable
31+
fun PasskeyCreated(
32+
state: PasskeyCreatedState,
33+
modifier: Modifier = Modifier,
34+
headerContent: @Composable (PasskeyCreatedState) -> Unit = {
35+
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
36+
Image(
37+
painter = painterResource(R.drawable.authenticator_success),
38+
contentDescription = null,
39+
modifier = Modifier.size(32.dp)
40+
)
41+
AuthenticatorTitle(stringResource(R.string.amplify_ui_authenticator_title_passkey_created))
42+
}
43+
},
44+
footerContent: @Composable (PasskeyCreatedState) -> Unit = { }
45+
) {
46+
val scope = rememberCoroutineScope()
47+
48+
Column(
49+
modifier = modifier
50+
.fillMaxWidth()
51+
.padding(horizontal = 16.dp)
52+
) {
53+
headerContent(state)
54+
55+
if (state.passkeys.isNotEmpty()) {
56+
Text(
57+
stringResource(R.string.amplify_ui_authenticator_existing_passkeys),
58+
style = MaterialTheme.typography.titleSmall
59+
)
60+
Spacer(modifier = Modifier.size(8.dp))
61+
Card {
62+
Column(modifier = Modifier.padding(16.dp).fillMaxWidth()) {
63+
state.passkeys.forEachIndexed { index, passkey ->
64+
Passkey(passkey)
65+
if (index != state.passkeys.size - 1) {
66+
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
67+
}
68+
}
69+
}
70+
}
71+
Spacer(modifier = Modifier.size(16.dp))
72+
}
73+
74+
var enabled by remember { mutableStateOf(true) }
75+
AuthenticatorButton(
76+
onClick = {
77+
scope.launch {
78+
enabled = false
79+
state.done()
80+
enabled = true
81+
}
82+
},
83+
loading = !enabled,
84+
label = stringResource(R.string.amplify_ui_authenticator_button_continue),
85+
modifier = Modifier.testTag(TestTags.ContinueButton)
86+
)
87+
88+
footerContent(state)
89+
}
90+
}
91+
92+
@Composable
93+
private fun Passkey(credential: AuthWebAuthnCredential) {
94+
Text(credential.friendlyName ?: "Unknown Passkey") // todo String resource
95+
}

0 commit comments

Comments
 (0)