Skip to content

Commit 5a0d7b9

Browse files
committed
[PM-15864] Add copy private key action for SSH keys
Adds a copy button to the private key field in SSH key entries, allowing users to easily copy the private key to the clipboard.
1 parent 3329dfa commit 5a0d7b9

File tree

6 files changed

+116
-3
lines changed

6 files changed

+116
-3
lines changed

app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSshKeyContent.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import androidx.compose.ui.unit.dp
1515
import com.x8bit.bitwarden.R
1616
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
1717
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTonalIconButton
18-
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField
18+
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordFieldWithActions
1919
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
2020
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextFieldWithActions
2121
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
@@ -84,12 +84,20 @@ fun VaultItemSshKeyContent(
8484

8585
item {
8686
Spacer(modifier = Modifier.height(8.dp))
87-
BitwardenPasswordField(
87+
BitwardenPasswordFieldWithActions(
8888
label = stringResource(id = R.string.private_key),
8989
value = sshKeyItemState.privateKey,
9090
onValueChange = { },
9191
singleLine = false,
9292
readOnly = true,
93+
actions = {
94+
BitwardenTonalIconButton(
95+
vectorIconRes = R.drawable.ic_copy,
96+
contentDescription = stringResource(id = R.string.copy_private_key),
97+
onClick = vaultSshKeyItemTypeHandlers.onCopyPrivateKeyClick,
98+
modifier = Modifier.testTag(tag = "SshKeyCopyPrivateKeyButton"),
99+
)
100+
},
93101
showPassword = sshKeyItemState.showPrivateKey,
94102
showPasswordTestTag = "ViewPrivateKeyButton",
95103
showPasswordChange = vaultSshKeyItemTypeHandlers.onShowPrivateKeyClick,

app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,8 @@ class VaultItemViewModel @Inject constructor(
790790
handlePrivateKeyVisibilityClicked(action)
791791
}
792792

793+
is VaultItemAction.ItemType.SshKey.CopyPrivateKeyClick -> handleCopyPrivateKeyClick()
794+
793795
VaultItemAction.ItemType.SshKey.CopyFingerprintClick -> handleCopyFingerprintClick()
794796
}
795797
}
@@ -814,6 +816,20 @@ class VaultItemViewModel @Inject constructor(
814816
}
815817
}
816818

819+
private fun handleCopyPrivateKeyClick() {
820+
onSshKeyContent { content, sshKey ->
821+
if (content.common.requiresReprompt) {
822+
updateDialogState(
823+
VaultItemState.DialogState.MasterPasswordDialog(
824+
action = PasswordRepromptAction.CopyClick(value = sshKey.privateKey),
825+
),
826+
)
827+
return@onSshKeyContent
828+
}
829+
clipboardManager.setText(text = sshKey.privateKey)
830+
}
831+
}
832+
817833
private fun handleCopyFingerprintClick() {
818834
onSshKeyContent { _, sshKey ->
819835
clipboardManager.setText(text = sshKey.fingerprint)
@@ -1950,6 +1966,11 @@ sealed class VaultItemAction {
19501966
*/
19511967
data class PrivateKeyVisibilityClicked(val isVisible: Boolean) : SshKey()
19521968

1969+
/**
1970+
* The user has clicked the copy button for the private key.
1971+
*/
1972+
data object CopyPrivateKeyClick : SshKey()
1973+
19531974
/**
19541975
* The user has clicked the copy button for the fingerprint.
19551976
*/

app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultSshKeyItemTypeHandlers.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemViewModel
1010
data class VaultSshKeyItemTypeHandlers(
1111
val onCopyPublicKeyClick: () -> Unit,
1212
val onShowPrivateKeyClick: (isVisible: Boolean) -> Unit,
13+
val onCopyPrivateKeyClick: () -> Unit,
1314
val onCopyFingerprintClick: () -> Unit,
1415
) {
1516

@@ -34,6 +35,11 @@ data class VaultSshKeyItemTypeHandlers(
3435
),
3536
)
3637
},
38+
onCopyPrivateKeyClick = {
39+
viewModel.trySendAction(
40+
VaultItemAction.ItemType.SshKey.CopyPrivateKeyClick,
41+
)
42+
},
3743
onCopyFingerprintClick = {
3844
viewModel.trySendAction(
3945
VaultItemAction.ItemType.SshKey.CopyFingerprintClick,

app/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,4 +1115,5 @@ Do you want to switch to this account?</string>
11151115
<string name="check_out_the_passphrase_generator">"Check out the passphrase generator"</string>
11161116
<string name="copied_to_clipboard">Copied to clipboard.</string>
11171117
<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>
1118+
<string name="copy_private_key">Copy private key</string>
11181119
</resources>

app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2545,6 +2545,18 @@ class VaultItemScreenTest : BaseComposeTest() {
25452545
}
25462546
}
25472547

2548+
@Test
2549+
fun `in ssh key state, on copy private key click should send CopyPrivateKeyClick`() {
2550+
mutableStateFlow.update { it.copy(viewState = DEFAULT_SSH_KEY_VIEW_STATE) }
2551+
composeTestRule
2552+
.onNodeWithContentDescriptionAfterScroll("Copy private key")
2553+
.performClick()
2554+
2555+
verify(exactly = 1) {
2556+
viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyPrivateKeyClick)
2557+
}
2558+
}
2559+
25482560
@Test
25492561
fun `in ssh key state, fingerprint should be displayed according to state`() {
25502562
val fingerprint = "the fingerprint"

app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2490,7 +2490,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
24902490

24912491
@Suppress("MaxLineLength")
24922492
@Test
2493-
fun `on PrivateKeyVisibilityClick should show password dialog when re-prompt is required`() =
2493+
fun `on PrivateKeyVisibilityClick should show password dialog when re-prompt is not required`() =
24942494
runTest {
24952495
val sshKeyViewState = createViewState(
24962496
common = DEFAULT_COMMON.copy(requiresReprompt = false),
@@ -2529,6 +2529,71 @@ class VaultItemViewModelTest : BaseViewModelTest() {
25292529
)
25302530
}
25312531

2532+
@Suppress("MaxLineLength")
2533+
@Test
2534+
fun `onPrivateKeyCopyClick should copy private key to clipboard when re-prompt is not required`() =
2535+
runTest {
2536+
every { clipboardManager.setText("mockPrivateKey") } just runs
2537+
every {
2538+
mockCipherView.toViewState(
2539+
previousState = null,
2540+
isPremiumUser = true,
2541+
hasMasterPassword = true,
2542+
totpCodeItemData = null,
2543+
canDelete = true,
2544+
canAssignToCollections = true,
2545+
)
2546+
} returns createViewState(
2547+
common = DEFAULT_COMMON.copy(requiresReprompt = false),
2548+
type = DEFAULT_SSH_KEY_TYPE,
2549+
)
2550+
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
2551+
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
2552+
mutableCollectionsStateFlow.value = DataState.Loaded(emptyList())
2553+
2554+
viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyPrivateKeyClick)
2555+
2556+
verify(exactly = 1) {
2557+
clipboardManager.setText(text = DEFAULT_SSH_KEY_TYPE.privateKey)
2558+
}
2559+
}
2560+
2561+
@Test
2562+
fun `onPrivateKeyCopyClick should show password dialog when re-prompt is required`() =
2563+
runTest {
2564+
val sshKeyState = DEFAULT_STATE.copy(viewState = SSH_KEY_VIEW_STATE)
2565+
every { clipboardManager.setText("mockPrivateKey") } just runs
2566+
every {
2567+
mockCipherView.toViewState(
2568+
previousState = null,
2569+
isPremiumUser = true,
2570+
hasMasterPassword = true,
2571+
totpCodeItemData = null,
2572+
canDelete = true,
2573+
canAssignToCollections = true,
2574+
)
2575+
} returns SSH_KEY_VIEW_STATE
2576+
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
2577+
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
2578+
mutableCollectionsStateFlow.value = DataState.Loaded(emptyList())
2579+
2580+
viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyPrivateKeyClick)
2581+
2582+
assertEquals(
2583+
sshKeyState.copy(
2584+
dialog = VaultItemState.DialogState.MasterPasswordDialog(
2585+
action = PasswordRepromptAction.CopyClick(
2586+
value = DEFAULT_SSH_KEY_TYPE.privateKey,
2587+
),
2588+
),
2589+
),
2590+
viewModel.stateFlow.value,
2591+
)
2592+
verify(exactly = 0) {
2593+
clipboardManager.setText(text = DEFAULT_SSH_KEY_TYPE.privateKey)
2594+
}
2595+
}
2596+
25322597
@Test
25332598
fun `on CopyFingerprintClick should copy fingerprint to clipboard`() = runTest {
25342599
every { clipboardManager.setText("mockFingerprint") } just runs

0 commit comments

Comments
 (0)