commit 141e3ae1fa95978c705fde47ad59444d84d96a78
parent 647baa5e653a7d7bd4837963396f2ad1d31e0470
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 19:58:01 +0000
ui(accounts): show generated key backup receipt
- render generated npub and nsec only from transient receipt state
- provide explicit clipboard and acknowledgement actions
- warn that loss of the generated secret removes account access
- verify the secret panel disappears only after acknowledgement
Diffstat:
4 files changed, 73 insertions(+), 1 deletion(-)
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt
@@ -30,6 +30,8 @@ data class StudioUiActions(
val editImportDraft: (String) -> Unit = {},
val generateAccount: () -> Unit = {},
val importSecretKey: () -> Unit = {},
+ val copyText: (String) -> Unit = {},
+ val acknowledgeGeneratedKeyBackup: () -> Unit = {},
)
@Composable
@@ -91,6 +93,10 @@ private fun InactiveAccountsScreen(
onClick = actions.importSecretKey,
)
+ model.generatedKeyBackup?.let { backup ->
+ GeneratedKeyBackupPanel(backup, actions)
+ }
+
model.problem?.let {
BasicText(it, Modifier.testTag("accounts-problem"))
}
@@ -102,6 +108,38 @@ private fun InactiveAccountsScreen(
}
@Composable
+private fun GeneratedKeyBackupPanel(
+ backup: GeneratedKeyBackupUiModel,
+ actions: StudioUiActions,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(InputBackgroundColor)
+ .padding(12.dp)
+ .testTag("generated-key-backup"),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ BasicText("Save this key")
+ BasicText("Losing this secret key means losing access to the account.")
+ BasicText(backup.npub)
+ BasicText(backup.nsec, Modifier.testTag("generated-nsec"))
+ TextAction(
+ text = "Copy",
+ testTag = "copy-generated-key",
+ contentDescription = "Copy generated Nostr secret key",
+ onClick = { actions.copyText(backup.nsec) },
+ )
+ TextAction(
+ text = "I have saved this key",
+ testTag = "acknowledge-key-backup",
+ contentDescription = "Confirm generated key backup",
+ onClick = actions.acknowledgeGeneratedKeyBackup,
+ )
+ }
+}
+
+@Composable
internal fun TextAction(
text: String,
testTag: String,
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt
@@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import java.awt.Toolkit
+import java.awt.datatransfer.StringSelection
import kotlinx.coroutines.CoroutineScope
import org.radroots.studio.accounts.ui.StudioScreen
import org.radroots.studio.accounts.ui.StudioUiActions
@@ -29,6 +31,10 @@ fun RadrootsApplication(
editImportDraft = store::editImportDraft,
generateAccount = store::generateAccount,
importSecretKey = store::importSecretKey,
+ copyText = { value ->
+ Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(value), null)
+ },
+ acknowledgeGeneratedKeyBackup = store::acknowledgeGeneratedKeyBackup,
),
)
}
diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt
@@ -5,6 +5,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -59,6 +61,32 @@ class StudioScreenTest {
onNodeWithText("The secret key is invalid.").assertIsDisplayed()
onNodeWithTag("accounts-empty").assertIsDisplayed()
}
+
+ @Test
+ fun generatedKeyBackupCopiesAndClearsOnlyAfterAcknowledgement() = runComposeUiTest {
+ var backup: GeneratedKeyBackupUiModel? by mutableStateOf(
+ GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"),
+ )
+ var copied: String? = null
+ setContent {
+ StudioScreen(
+ model = emptyUiModel().copy(generatedKeyBackup = backup),
+ actions = StudioUiActions(
+ copyText = { copied = it },
+ acknowledgeGeneratedKeyBackup = { backup = null },
+ ),
+ )
+ }
+
+ onNodeWithTag("generated-key-backup").assertIsDisplayed()
+ onNodeWithTag("generated-nsec").assertIsDisplayed()
+ onNodeWithTag("copy-generated-key").performClick()
+ assertEquals("nsec1generated", copied)
+
+ onNodeWithTag("acknowledge-key-backup").performClick()
+ onAllNodesWithTag("generated-key-backup").assertCountEquals(0)
+ onAllNodesWithTag("generated-nsec").assertCountEquals(0)
+ }
}
private fun emptyUiModel(
diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md
@@ -535,7 +535,7 @@ handoff commit sequence.
- [x] 49. Bootstrap AppCore once at application root.
- [x] 50. Create UI model mapping helpers without server fields.
- [x] 51. Implement inactive account screen generate/import controls.
-- [ ] 52. Implement generated-key backup panel.
+- [x] 52. Implement generated-key backup panel.
- [ ] 53. Implement saved-account list, activation, and removal controls.
- [ ] 54. Implement active account home screen.
- [ ] 55. Implement switch account flow in UI.