app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 2ca15c6d9a018e462ec8cde2d5d208e4926798f0
parent 7e67a4b1726f17764678a9cf3455668deb9893b4
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 23:57:19 +0000

custody: make generated recovery exclusive

- replace the account surface with a one-key recovery route
- support explicit acknowledgement and cancellation paths
- clear transient recovery material after either terminal action
- cover exclusive presentation commit and cancellation behavior

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt | 23+++++++++++++++--------
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt | 1+
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 17+++++++++++++++++
Mapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt | 25+++++++++++++++++++++++++
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 17+++++++++++++++++
5 files changed, 75 insertions(+), 8 deletions(-)

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 @@ -41,6 +41,7 @@ data class StudioUiActions( val importSecretKey: () -> Unit = {}, val copyText: (String) -> Unit = {}, val acknowledgeGeneratedKeyBackup: () -> Unit = {}, + val cancelGeneratedKeyBackup: () -> Unit = {}, val selectAccount: (String) -> Unit = {}, val activateAccount: (String) -> Unit = {}, val requestAccountRemoval: (String) -> Unit = {}, @@ -72,6 +73,10 @@ fun StudioScreen( model: StudioUiModel, actions: StudioUiActions, ) { + model.generatedKeyBackup?.let { backup -> + GeneratedKeyRecoveryScreen(backup, actions) + return + } when (model.route) { StudioRoute.OPENING -> LifecycleScreen("Opening local account store", "lifecycle-opening") StudioRoute.CHECKING_COMPATIBILITY -> LifecycleScreen( @@ -215,10 +220,6 @@ private fun InactiveAccountsScreen( AccountEntry(model, actions) - model.generatedKeyBackup?.let { backup -> - GeneratedKeyBackupPanel(backup, actions) - } - model.problem?.let { BasicText(it, Modifier.testTag("accounts-problem")) } @@ -366,15 +367,15 @@ private fun ColumnScope.SavedAccountList( } @Composable -private fun GeneratedKeyBackupPanel( +private fun GeneratedKeyRecoveryScreen( backup: GeneratedKeyBackupUiModel, actions: StudioUiActions, ) { Column( modifier = Modifier - .fillMaxWidth() - .background(InputBackgroundColor) - .padding(12.dp) + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) .testTag("generated-key-backup"), verticalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -389,6 +390,12 @@ private fun GeneratedKeyBackupPanel( onClick = { actions.copyText(backup.nsec) }, ) TextAction( + text = "Cancel", + testTag = "cancel-generated-key", + contentDescription = "Cancel generated account", + onClick = actions.cancelGeneratedKeyBackup, + ) + TextAction( text = "I have saved this key", testTag = "acknowledge-key-backup", contentDescription = "Confirm generated key backup", 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 @@ -49,6 +49,7 @@ fun RadrootsApplication( importSecretKey = store::importSecretKey, copyText = { value -> clipboard.copy(value) }, acknowledgeGeneratedKeyBackup = store::acknowledgeGeneratedKeyBackup, + cancelGeneratedKeyBackup = store::cancelGeneratedKeyBackup, selectAccount = store::selectAccount, activateAccount = store::activateAccount, requestAccountRemoval = store::requestAccountRemoval, diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt @@ -141,6 +141,23 @@ class StudioAppStore( } } + fun cancelGeneratedKeyBackup() { + val recovery = pendingGeneratedRecovery ?: run { + rejectUnavailableIntent("Generated-key recovery is not available.") + return + } + pendingGeneratedRecovery = null + launchCommand { + try { + recovery.cancel() + generatedRecovery.acknowledge() + mutableState.value = mutableState.value.copy(generatedKeyBackup = null) + } finally { + recovery.close() + } + } + } + fun importSecretKey() { if (rejectIfUnavailable()) return val input = mutableState.value.importDraft.encodeToByteArray() 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 @@ -118,6 +118,8 @@ class StudioScreenTest { } onNodeWithTag("generated-key-backup").assertIsDisplayed() + onAllNodesWithTag("accounts-screen").assertCountEquals(0) + onAllNodesWithTag("generate-key").assertCountEquals(0) onNodeWithTag("generated-nsec").assertIsDisplayed() onNodeWithTag("copy-generated-key").performClick() assertEquals("nsec1generated", copied) @@ -128,6 +130,29 @@ class StudioScreenTest { } @Test + fun generatedKeyRecoveryCanBeCancelledWithoutExposingAccountControls() = runComposeUiTest { + var backup: GeneratedKeyBackupUiModel? by mutableStateOf( + GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), + ) + var cancelled = 0 + setContent { + StudioScreen( + model = emptyUiModel().copy(generatedKeyBackup = backup), + actions = StudioUiActions( + cancelGeneratedKeyBackup = { + cancelled += 1 + backup = null + }, + ), + ) + } + + onNodeWithTag("cancel-generated-key").performClick() + assertEquals(1, cancelled) + onAllNodesWithTag("generated-key-backup").assertCountEquals(0) + } + + @Test fun savedAccountsSelectActivateAndRequireRemovalConfirmation() = runComposeUiTest { val first = accountUi("11".repeat(32), selected = true) val second = accountUi("22".repeat(32), selected = false) diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt @@ -57,6 +57,23 @@ class StudioAppStoreTest { } @Test + fun `cancels staged generated account without committing it`() = runTest { + val gateway = FakeStudioCoreGateway(snapshot(0UL)) + val store = StudioAppStore(gateway, this) + advanceUntilIdle() + val revisionBeforeGeneration = store.state.value.snapshot.revision + store.generateAccount() + advanceUntilIdle() + + store.cancelGeneratedKeyBackup() + advanceUntilIdle() + + assertNull(store.state.value.generatedKeyBackup) + assertEquals(revisionBeforeGeneration, store.state.value.snapshot.revision) + store.close() + } + + @Test fun `ignores observer delivery after close`() = runTest { val gateway = FakeStudioCoreGateway(snapshot(0UL)) val store = StudioAppStore(gateway, this)