From f32eecc0d782d57bf24484d2d9583b24a539b2de Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Fri, 20 Dec 2024 14:44:31 -0500 Subject: [PATCH] [PM-15864] Add copy private key action for SSH keys (#4462) --- .../feature/item/VaultItemSshKeyContent.kt | 12 +++- .../vault/feature/item/VaultItemViewModel.kt | 21 ++++++ .../handlers/VaultSshKeyItemTypeHandlers.kt | 6 ++ app/src/main/res/values/strings.xml | 1 + .../vault/feature/item/VaultItemScreenTest.kt | 12 ++++ .../feature/item/VaultItemViewModelTest.kt | 65 +++++++++++++++++++ 6 files changed, 115 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSshKeyContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSshKeyContent.kt index 3aac1c388e..6c61e4b1be 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSshKeyContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSshKeyContent.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.unit.dp import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTonalIconButton -import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField +import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordFieldWithActions import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextFieldWithActions import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText @@ -84,12 +84,20 @@ fun VaultItemSshKeyContent( item { Spacer(modifier = Modifier.height(8.dp)) - BitwardenPasswordField( + BitwardenPasswordFieldWithActions( label = stringResource(id = R.string.private_key), value = sshKeyItemState.privateKey, onValueChange = { }, singleLine = false, readOnly = true, + actions = { + BitwardenTonalIconButton( + vectorIconRes = R.drawable.ic_copy, + contentDescription = stringResource(id = R.string.copy_private_key), + onClick = vaultSshKeyItemTypeHandlers.onCopyPrivateKeyClick, + modifier = Modifier.testTag(tag = "SshKeyCopyPrivateKeyButton"), + ) + }, showPassword = sshKeyItemState.showPrivateKey, showPasswordTestTag = "ViewPrivateKeyButton", showPasswordChange = vaultSshKeyItemTypeHandlers.onShowPrivateKeyClick, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index 4b04861e40..c57ef42afc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -790,6 +790,8 @@ class VaultItemViewModel @Inject constructor( handlePrivateKeyVisibilityClicked(action) } + is VaultItemAction.ItemType.SshKey.CopyPrivateKeyClick -> handleCopyPrivateKeyClick() + VaultItemAction.ItemType.SshKey.CopyFingerprintClick -> handleCopyFingerprintClick() } } @@ -824,6 +826,20 @@ class VaultItemViewModel @Inject constructor( } } + private fun handleCopyPrivateKeyClick() { + onSshKeyContent { content, sshKey -> + if (content.common.requiresReprompt) { + updateDialogState( + VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.CopyClick(value = sshKey.privateKey), + ), + ) + return@onSshKeyContent + } + clipboardManager.setText(text = sshKey.privateKey) + } + } + private fun handleCopyFingerprintClick() { onSshKeyContent { _, sshKey -> clipboardManager.setText(text = sshKey.fingerprint) @@ -1960,6 +1976,11 @@ sealed class VaultItemAction { */ data class PrivateKeyVisibilityClicked(val isVisible: Boolean) : SshKey() + /** + * The user has clicked the copy button for the private key. + */ + data object CopyPrivateKeyClick : SshKey() + /** * The user has clicked the copy button for the fingerprint. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultSshKeyItemTypeHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultSshKeyItemTypeHandlers.kt index 340ba90d15..163abb034c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultSshKeyItemTypeHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultSshKeyItemTypeHandlers.kt @@ -10,6 +10,7 @@ import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemViewModel data class VaultSshKeyItemTypeHandlers( val onCopyPublicKeyClick: () -> Unit, val onShowPrivateKeyClick: (isVisible: Boolean) -> Unit, + val onCopyPrivateKeyClick: () -> Unit, val onCopyFingerprintClick: () -> Unit, ) { @@ -34,6 +35,11 @@ data class VaultSshKeyItemTypeHandlers( ), ) }, + onCopyPrivateKeyClick = { + viewModel.trySendAction( + VaultItemAction.ItemType.SshKey.CopyPrivateKeyClick, + ) + }, onCopyFingerprintClick = { viewModel.trySendAction( VaultItemAction.ItemType.SshKey.CopyFingerprintClick, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0fc910dd44..4d757f6fee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1124,4 +1124,5 @@ Do you want to switch to this account? Copied to clipboard. We couldn’t verify the server’s certificate. The certificate chain or proxy settings on your device or your Bitwarden server may not be set up correctly. Review flow launched! + Copy private key diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index 3cfe234bea..c081ef6c8a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -2545,6 +2545,18 @@ class VaultItemScreenTest : BaseComposeTest() { } } + @Test + fun `in ssh key state, on copy private key click should send CopyPrivateKeyClick`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_SSH_KEY_VIEW_STATE) } + composeTestRule + .onNodeWithContentDescriptionAfterScroll("Copy private key") + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyPrivateKeyClick) + } + } + @Test fun `in ssh key state, fingerprint should be displayed according to state`() { val fingerprint = "the fingerprint" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index 9eb5496eeb..a5579663a2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -2583,6 +2583,71 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `onPrivateKeyCopyClick should copy private key to clipboard when re-prompt is not required`() = + runTest { + every { clipboardManager.setText("mockPrivateKey") } just runs + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = true, + canAssignToCollections = true, + ) + } returns createViewState( + common = DEFAULT_COMMON.copy(requiresReprompt = false), + type = DEFAULT_SSH_KEY_TYPE, + ) + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + + viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyPrivateKeyClick) + + verify(exactly = 1) { + clipboardManager.setText(text = DEFAULT_SSH_KEY_TYPE.privateKey) + } + } + + @Test + fun `onPrivateKeyCopyClick should show password dialog when re-prompt is required`() = + runTest { + val sshKeyState = DEFAULT_STATE.copy(viewState = SSH_KEY_VIEW_STATE) + every { clipboardManager.setText("mockPrivateKey") } just runs + every { + mockCipherView.toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + canDelete = true, + canAssignToCollections = true, + ) + } returns SSH_KEY_VIEW_STATE + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) + + viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyPrivateKeyClick) + + assertEquals( + sshKeyState.copy( + dialog = VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.CopyClick( + value = DEFAULT_SSH_KEY_TYPE.privateKey, + ), + ), + ), + viewModel.stateFlow.value, + ) + verify(exactly = 0) { + clipboardManager.setText(text = DEFAULT_SSH_KEY_TYPE.privateKey) + } + } + @Test fun `on CopyFingerprintClick should copy fingerprint to clipboard`() = runTest { every { clipboardManager.setText("mockFingerprint") } just runs