Skip to content

Commit

Permalink
[PM-15864] Add copy private key action for SSH keys
Browse files Browse the repository at this point in the history
Adds a copy button to the private key field in SSH key entries, allowing users to easily copy the private key to the clipboard.
  • Loading branch information
SaintPatrck committed Dec 20, 2024
1 parent c4c7af5 commit c09fb14
Show file tree
Hide file tree
Showing 6 changed files with 115 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,8 @@ class VaultItemViewModel @Inject constructor(
handlePrivateKeyVisibilityClicked(action)
}

is VaultItemAction.ItemType.SshKey.CopyPrivateKeyClick -> handleCopyPrivateKeyClick()

VaultItemAction.ItemType.SshKey.CopyFingerprintClick -> handleCopyFingerprintClick()
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {

Expand All @@ -34,6 +35,11 @@ data class VaultSshKeyItemTypeHandlers(
),
)
},
onCopyPrivateKeyClick = {
viewModel.trySendAction(
VaultItemAction.ItemType.SshKey.CopyPrivateKeyClick,
)
},
onCopyFingerprintClick = {
viewModel.trySendAction(
VaultItemAction.ItemType.SshKey.CopyFingerprintClick,
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1124,4 +1124,5 @@ Do you want to switch to this account?</string>
<string name="copied_to_clipboard">Copied to clipboard.</string>
<string name="we_couldnt_verify_the_servers_certificate">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.</string>
<string name="review_flow_launched">Review flow launched!</string>
<string name="copy_private_key">Copy private key</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit c09fb14

Please sign in to comment.