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
10 changes: 8 additions & 2 deletions source/api/src/main/kotlin/com/clerk/api/network/api/UserApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -402,29 +402,34 @@ internal interface UserApi {
* Retrieves a specific passkey by its ID.
*
* @param passkeyId The ID of the passkey to retrieve
* @param sessionId Optional session ID. Defaults to current session ID from [Clerk.session]
* @return [ClerkResult] containing the [Passkey] on success or [ClerkErrorResponse] on failure
*/
@GET(ApiPaths.User.Passkey.WITH_ID)
suspend fun getPasskey(
@Path(ApiParams.PASSKEY_ID) passkeyId: String
@Path(ApiParams.PASSKEY_ID) passkeyId: String,
@Query(ApiParams.CLERK_SESSION_ID) sessionId: String? = Clerk.session?.id,
): ClerkResult<Passkey, ClerkErrorResponse>

/**
* Deletes a specific passkey.
*
* @param passkeyId The ID of the passkey to delete
* @param sessionId Optional session ID. Defaults to current session ID from [Clerk.session]
* @return [ClerkResult] containing [DeletedObject] on success or [ClerkErrorResponse] on failure
*/
@DELETE(ApiPaths.User.Passkey.WITH_ID)
suspend fun deletePasskey(
@Path(ApiParams.PASSKEY_ID) passkeyId: String
@Path(ApiParams.PASSKEY_ID) passkeyId: String,
@Query(ApiParams.CLERK_SESSION_ID) sessionId: String? = Clerk.session?.id,
): ClerkResult<DeletedObject, ClerkErrorResponse>

/**
* Updates properties of a specific passkey.
*
* @param passkeyId The ID of the passkey to update
* @param name Optional: New name for the passkey
* @param sessionId Optional session ID. Defaults to current session ID from [Clerk.session]
* @return [ClerkResult] containing the updated [Passkey] on success or [ClerkErrorResponse] on
* failure
*/
Expand All @@ -433,6 +438,7 @@ internal interface UserApi {
suspend fun updatePasskey(
@Path(ApiParams.PASSKEY_ID) passkeyId: String,
@Field("name") name: String? = null,
@Query(ApiParams.CLERK_SESSION_ID) sessionId: String? = Clerk.session?.id,
): ClerkResult<Passkey, ClerkErrorResponse>

/**
Expand Down
38 changes: 38 additions & 0 deletions source/api/src/test/java/com/clerk/api/network/api/UserApiTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.clerk.api.network.api

import com.clerk.api.network.ApiParams
import kotlin.test.Test
import kotlin.test.assertTrue
import retrofit2.http.Query

class UserApiTest {

@Test
fun `passkey endpoints include clerk session id query`() {
val passkeyMethods =
listOf(
"createPasskey",
"getPasskey",
"deletePasskey",
"updatePasskey",
"attemptPasskeyVerification",
)

passkeyMethods.forEach { methodName ->
assertTrue(
method(methodName).hasQuery(ApiParams.CLERK_SESSION_ID),
"$methodName should include _clerk_session_id",
)
}
}

private fun method(name: String): java.lang.reflect.Method {
return UserApi::class.java.methods.single { it.name == name }
}

private fun java.lang.reflect.Method.hasQuery(value: String): Boolean {
return parameterAnnotations.any { annotations ->
annotations.filterIsInstance<Query>().any { it.value == value }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,20 @@ private fun UserProfilePasskeyRenameViewImpl(
viewModel: UserProfilePasskeyRenameViewModel = viewModel(),
) {
val userProfileState = LocalUserProfileState.current
var passkeyNameInput by rememberSaveable { mutableStateOf(passkeyName) }
var originalPasskeyName by rememberSaveable { mutableStateOf(passkeyName) }
var passkeyNameInput by rememberSaveable(passkeyId) { mutableStateOf(passkeyName) }
val originalPasskeyName = rememberSaveable(passkeyId) { passkeyName }
val updatedPasskeyName =
updatedPasskeyNameOrNull(
passkeyNameInput = passkeyNameInput,
originalPasskeyName = originalPasskeyName,
)
val state by viewModel.state.collectAsStateWithLifecycle()
val errorMessage = (state as? UserProfilePasskeyRenameViewModel.State.Error)?.message

LaunchedEffect(state) {
if (state is UserProfilePasskeyRenameViewModel.State.Success) {
userProfileState.navigateBack()
viewModel.resetState()
}
}

Expand All @@ -77,13 +83,24 @@ private fun UserProfilePasskeyRenameViewImpl(
ClerkButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.save),
isEnabled = passkeyName.isNotBlank() && passkeyName != originalPasskeyName,
isEnabled = updatedPasskeyName != null,
isLoading = state is UserProfilePasskeyRenameViewModel.State.Loading,
onClick = { viewModel.renamePasskey(passkeyId = passkeyId, newName = passkeyName) },
onClick = {
updatedPasskeyName?.let { viewModel.renamePasskey(passkeyId = passkeyId, newName = it) }
},
)
}
}

internal fun updatedPasskeyNameOrNull(
passkeyNameInput: String,
originalPasskeyName: String,
): String? {
return passkeyNameInput.takeIf {
passkeyNameInput.isNotBlank() && passkeyNameInput != originalPasskeyName
}
}

@PreviewLightDark
@Composable
private fun Preview() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ internal class UserProfilePasskeyRenameViewModel : ViewModel() {
}
}

fun resetState() {
_state.value = State.Idle
}

sealed interface State {
data object Idle : State

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.clerk.ui.userprofile.security.passkey.rename

import app.cash.turbine.test
import com.clerk.api.Clerk
import com.clerk.api.network.serialization.ClerkResult
import com.clerk.api.passkeys.Passkey
import com.clerk.api.passkeys.update
import com.clerk.api.user.User
import com.clerk.ui.userprofile.MainDispatcherRule
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.unmockkStatic
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest

@OptIn(ExperimentalCoroutinesApi::class)
class UserProfilePasskeyRenameViewModelTest {

@get:org.junit.Rule val dispatcherRule = MainDispatcherRule()

@BeforeTest
fun setUp() {
mockkObject(Clerk)
mockkStatic("com.clerk.api.passkeys.PasskeyKt")
}

@AfterTest
fun tearDown() {
unmockkStatic("com.clerk.api.passkeys.PasskeyKt")
unmockkAll()
}

@Test
fun `successful rename state can be reset before reopening`() = runTest {
val passkey = mockk<Passkey>()
val user = mockk<User>()
every { passkey.id } returns "passkey_123"
every { user.passkeys } returns listOf(passkey)
every { Clerk.user } returns user
coEvery { passkey.update(name = "Work laptop") } returns ClerkResult.success(passkey)

val viewModel = UserProfilePasskeyRenameViewModel()
viewModel.state.test {
assertEquals(UserProfilePasskeyRenameViewModel.State.Idle, awaitItem())

viewModel.renamePasskey(passkeyId = "passkey_123", newName = "Work laptop")
assertEquals(UserProfilePasskeyRenameViewModel.State.Success, awaitItem())

viewModel.resetState()
assertEquals(UserProfilePasskeyRenameViewModel.State.Idle, awaitItem())
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.clerk.ui.userprofile.security.passkey.rename

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class UserProfilePasskeyRenameViewTest {

@Test
fun `updated passkey name is null when input is unchanged`() {
assertNull(
updatedPasskeyNameOrNull(
passkeyNameInput = "Personal phone",
originalPasskeyName = "Personal phone",
)
)
}

@Test
fun `updated passkey name is null when input is blank`() {
assertNull(
updatedPasskeyNameOrNull(passkeyNameInput = " ", originalPasskeyName = "Personal phone")
)
}

@Test
fun `updated passkey name returns changed input`() {
assertEquals(
"Work laptop",
updatedPasskeyNameOrNull(
passkeyNameInput = "Work laptop",
originalPasskeyName = "Personal phone",
),
)
}
}