diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index 5f597e2aa8..422bfb4e25 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -25,6 +25,17 @@ I have saved my keys somewhere safe Copy encrypted (recommended) Password for encrypted backup + Step %1$d of %2$d + Next + Back + Cancel + Save your keys before you continue + Your public key (npub) is your shareable address. Give it out freely so people can find and follow you. + Your secret key (nsec) is a password that can NEVER be reset. This is the only time it is shown — save it now, or you will lose access to this account forever. + Your keys + One last thing + Store your secret key in a password manager. If you lose it, no one can recover it for you. + This is a read-only account. No secret key was generated, so there is nothing to back up. Backup Keys diff --git a/desktopApp/plans/2026-08-11-key-backup-manual-testing.md b/desktopApp/plans/2026-08-11-key-backup-manual-testing.md new file mode 100644 index 0000000000..2331f62327 --- /dev/null +++ b/desktopApp/plans/2026-08-11-key-backup-manual-testing.md @@ -0,0 +1,139 @@ +# Key Backup & nsec Exposure — Manual Testing Sheet + +Branch: `feat/key-backup-nsec-exposure` (worktree `.claude/worktrees/feat-key-backup`) +Date: 2026-08-11 +Build: Desktop `./gradlew :desktopApp:run` · Android `./gradlew :amethyst:installPlayDebug` + +Legend: ✅ pass · ❌ fail (note what happened) · ⏭️ skipped + +Design invariants to keep verifying throughout: +- **npub** = shareable → plain, copyable, QR OK. +- **nsec** = unrecoverable password → masked by default, gated reveal + gated copy, + encrypted (NIP-49) option, **NEVER shown as a QR code**. + +--- + +## A. Desktop — New-account warning card (`NewKeyWarningCard`) + +Precondition: launch Desktop, log out / add account, choose **Generate New Account**. + +- [ ] A1. Card shows title + strengthened warning wording ("can never be reset/recovered"). +- [ ] A2. **npub** shown; its **Copy** button copies → paste elsewhere matches the npub. +- [ ] A3. **nsec** shown; its **Copy** button copies → paste matches the nsec (`nsec1…`). +- [ ] A4. **Copy encrypted (recommended)**: type a password → button copies an `ncryptsec1…` + string (paste to verify prefix). Empty password → button disabled / no-op. +- [ ] A5. **"I have saved my keys"** checkbox: Continue is disabled until checked (soft gate); + checking it enables Continue. +- [ ] A6. Continue proceeds into the app with the generated account logged in. +- [ ] A7. **No QR code** anywhere on this card. + +## B. Desktop — Backup Keys card in Settings/Profile (`BackupKeysCard`) + +Precondition: logged in with an **internal-key** account (has nsec). Open Settings → Profile. + +- [ ] B1. "Backup Keys" card is visible in the profile/settings screen (discoverable — the + original complaint was "couldn't find it in settings"). +- [ ] B2. **npub** row: monospace value + **Copy** works; **Show QR** renders a QR; **Hide QR** hides it. +- [ ] B3. QR scans/points to the npub (optional: scan with a phone). +- [ ] B4. **nsec** section: warning banner shown; key is **masked** ("hidden" placeholder), + not revealed on load. + +### B-lock. Reveal gating via PrivacyLock + +Case 1 — PrivacyLock **NOT** set up (no master password configured): +- [ ] B5. Click **Reveal secret key** → nsec reveals immediately (baseline: masked + explicit + toggle, no password). Acceptable per design. + +Case 2 — PrivacyLock **enabled** (set a master password in Messages/Wallet privacy-lock settings first): +- [ ] B6. Click **Reveal secret key** → a **modal Dialog** appears asking to unlock (does NOT + take over / expand the whole settings pane). +- [ ] B7. Wrong password → stays locked; **Cancel/dismiss** the dialog → nsec stays masked. +- [ ] B8. Correct password → dialog closes, nsec reveals. + +### B-copy. Revealed secret-key actions +- [ ] B9. **Copy secret key** (plaintext) copies the `nsec1…`; a red plaintext warning is visible. +- [ ] B10. **Clipboard auto-clear**: after copying plaintext nsec, wait ~60s without copying + anything else → paste → clipboard is **empty**. If you copy something else within 60s, + that value is **preserved** (auto-clear only wipes if clipboard still holds the nsec). +- [ ] B11. **Copy encrypted (recommended)**: enter password → copies `ncryptsec1…`; toggle the + password visibility eye works; wrong/blank handled (button disabled while blank; failure + shows the error supporting text). Encrypted copy is **not** auto-cleared (it's password-safe). +- [ ] B12. **Hide** returns the section to masked state; leaving the screen and returning re-hides. +- [ ] B13. **No QR** is ever offered for the nsec. + +## C. Desktop — External-signer / read-only account + +Precondition: log in with an **external signer / bunker (NIP-46)** or a **read-only npub**. + +- [ ] C1. Backup Keys card shows the npub section normally. +- [ ] C2. nsec section is replaced by the "This account uses an external signer — no secret key + is stored here" note. No reveal/copy controls, no masked field. + +--- + +## D. Android — Post-signup backup nudge + +Precondition: fresh install or logged out. **Create a NEW account** (generate). + +- [ ] D1. After signup lands on the home feed, a dismissible **"Back up your keys"** nudge/banner + appears (top of feed, above the algo-feed status banner; does not block navigation). +- [ ] D2. **Back up now** → opens the existing Account Backup screen; returning home, the nudge + is gone (flag flipped). +- [ ] D3. Re-create another new account → **I saved them** (or the X) dismisses the nudge. +- [ ] D4. Kill & relaunch the app → the dismissed nudge does **not** reappear for that account + (per-account `hasBackedUpKeys` persisted in encrypted prefs). + +## E. Android — Which accounts get nudged + +- [ ] E1. Log in with an **existing nsec** (paste key) → **no** nudge (treated as already backed up). +- [ ] E2. Log in with **bunker / external signer** → **no** nudge. +- [ ] E3. Read-only **npub** login → **no** nudge (no private key). +- [ ] E4. Multiple accounts: a freshly-generated account is nudged; switching to a + pre-existing account shows no nudge (flag is per-account). + +## F. Android — Backup screen hardening (`AccountBackupScreen`) + +- [ ] F1. **FLAG_SECURE**: on the Account Backup screen, attempt a screenshot → blocked by the OS + ("can't take screenshots due to security policy") and the app-switcher/recents preview shows + a blank/black thumbnail for this screen. +- [ ] F2. Navigating away from the backup screen → screenshots work again elsewhere (flag cleared, + no leak to other screens). +- [ ] F3. Existing **biometric gate** on copy/QR still prompts and works. +- [ ] F4. **Copy secret key** (plaintext) → toast shown; **clipboard auto-clear** after ~60s + empties the clipboard if unchanged; a value copied in the meantime is preserved. +- [ ] F5. **Encrypted (ncryptsec1) copy** and the **plaintext / encrypted QR codes** still work + as before (regression check — these are pre-existing). + +--- + +## G. Cross-cutting — NIP-49 round trip (correctness) + +- [ ] G1. Desktop: encrypted-copy the nsec with password `P` → you have an `ncryptsec1…`. +- [ ] G2. Log in (Desktop or Android) using that `ncryptsec1…` + password `P` → succeeds and + resolves to the **same** account (same npub). Confirms the nsec→hex decode + Nip49 encrypt + are correct end-to-end. +- [ ] G3. Wrong password on login with the ncryptsec → rejected (no crash). + +## H. Regression / smoke + +- [ ] H1. Desktop **Developer Settings** key rows still copy (shared `copyToClipboard` refactor + didn't break them). +- [ ] H2. Privacy lock still gates **Messages** and **Wallet** as before (adding `KeyBackup` + scope didn't disturb existing scopes). +- [ ] H3. Normal posting on Android still works (paste-guard was **deferred** — a note containing + an `nsec1…` currently posts without a warning; confirm posting itself is unaffected). + +--- + +## Known limitations / deferred (expected, not bugs) +- **Compose paste-guard** (warn before posting a note that contains an `nsec1…`) is **deferred** — + the send path is reimplemented across ~17 `*PostViewModel`s with no shared choke point. +- **Desktop reveal without PrivacyLock** is protected only by masked + explicit toggle (no + password), by design — the master-password gate only engages if the user set one up. +- **Clipboard auto-clear** is best-effort (equality-guarded, 60s); the OS may surface its own + sensitive-clipboard UI on Android 13+. + +## Sign-off +- Tester: __________ Date: __________ +- Desktop OS: __________ Android version/device: __________ +- Overall: ☐ ready for PR ☐ needs fixes (list): __________ diff --git a/desktopApp/plans/2026-08-11-new-account-key-onboarding-plan.md b/desktopApp/plans/2026-08-11-new-account-key-onboarding-plan.md new file mode 100644 index 0000000000..01a9ab3536 --- /dev/null +++ b/desktopApp/plans/2026-08-11-new-account-key-onboarding-plan.md @@ -0,0 +1,103 @@ +# First-Run "Save Your Keys" Onboarding — Plan (Desktop) + +Date: 2026-08-11 +Branch: `feat/key-backup-nsec-exposure` +Supersedes: the single `NewKeyWarningCard` for freshly-generated accounts. + +## Why + +New-account key backup is the highest-stakes moment in a Nostr client: the +`nsec` is shown once and can never be reset. Current impl crams warning + npub + +nsec + encrypted-copy + checkbox + continue into one `NewKeyWarningCard`. Inside +the 480px add-account dialog it has no scroll, so it clips — labels above and the +Continue button below are cut off → reads as "no label / no way forward". Even +un-clipped, one dense card is confusing. + +Decision (user): **guided multi-step full-screen flow**, **soft checkbox gate**. + +## What already exists (reuse — don't rebuild) + +- **Split generation** (just built): `AccountManager.buildNewAccount()` creates the + keypair WITHOUT activating it; `activateAccount(state)` flips account state. + This is what lets a backup step render before the account switch tears the + screen down. Keep. +- **Entry points** that generate a key: + - Cold start: `LoginScreen` (`onGenerateNew` → `buildNewAccount()`). + - Logged-in: `AddAccountDialog` (`onGenerateNew` → `buildNewAccount()`), confirmed + via `onNewAccountConfirmed` on the App scope. +- **Pieces to lift out of `NewKeyWarningCard`**: `CopyKeyButton`, `EncryptedCopyRow` + (NIP-49 password → `ncryptsec1`), `SelectableKeyText`, the warning strings + (`new_key_*`). The `nsec→hex` decode (`decodePrivateKeyAsHexOrNull`) + `Nip49`. +- **QR**: `QrCodeCanvas(data)` — npub only, never nsec. +- **Settings** backup card (`BackupKeysCard`) already covers "view my keys later". + +## Design — `NewKeyOnboardingScreen` (stepper, 3 steps) + +A full-window composable (NOT a dialog). Rendered while `buildNewAccount()` result +is held and before `activateAccount`. Fixed max content width (~560dp), centered, +each step vertically scrollable so nothing clips. + +**Step 1 — Why this matters (education)** +- Headline "Save your keys" + plain-language explainer: npub = your public + identity (shareable); nsec = a password that can never be reset or recovered — + lose it and the account is gone. No inputs. [Next]. + +**Step 2 — Your keys (the actual backup)** +- **Public key (npub)**: monospace, Copy, optional Show QR. +- **Secret key (nsec)**: monospace (shown — this is the one moment we intentionally + reveal it), **Copy** (primary, prominent) + best-effort clipboard auto-clear. +- **Copy encrypted (recommended)**: collapsible/secondary — password field → + `ncryptsec1`. Keep out of the way so the primary Copy is obvious. +- Strong "never share the nsec" inline warning. No nsec QR. [Back] [Next]. + +**Step 3 — Confirm & continue (soft gate)** +- Recap one line ("Stored somewhere safe? A password manager is ideal."). +- **Soft checkbox** "I have saved my keys somewhere safe" → enables **Continue**. + (Honor-system, matches the Android nudge decision. No copy/verify enforcement.) +- Continue → `activateAccount(state)` + persist + proceed into the app. + +Progress indicator (e.g. "Step 2 of 3" or dots). [Back] on 2/3. + +## Integration + +- **New file**: `desktopApp/.../ui/auth/NewKeyOnboardingScreen.kt` (stepper + + step composables; reuse the extracted Copy/Encrypt pieces). +- **Cold start**: in `LoginScreen`, when `generatedAccount != null` render the + onboarding screen full-bleed instead of the inline card. `onFinish` = + `activateAccount` + `onLoginSuccess`. +- **Add-account**: when a key is generated, DON'T keep it inside the small dialog. + Either (a) dismiss the dialog and show the onboarding screen at the App level + (preferred — full space, survives the later account switch), or (b) let the + onboarding screen be the dialog's content at a larger, scrollable size. Pick (a) + for consistency with the cold-start path: hoist a top-level + `pendingNewAccount: AccountState.LoggedIn?` in the App composable; both entry + points set it; one `NewKeyOnboardingScreen` renders when non-null; `onFinish` + activates + persists on the App scope. This unifies both flows through one + screen and removes the dialog-clipping problem entirely. +- **Retire** `NewKeyWarningCard` (and its Preview) once both paths use the stepper. + +## Testing (add to the manual sheet) +- Cold start: log out → Generate → 3-step screen, no clipping, Copy works, + encrypted copy → `ncryptsec1`, checkbox gates Continue, Continue lands in app. +- Add account (already logged in): Add → Generate → same screen at App level (not a + cramped dialog), Continue switches to the new account + persists (survives + restart). +- Back/Next preserve state; window resize keeps everything reachable (scroll). +- NIP-49 round trip (encrypted copy → login elsewhere → same npub). + +## Non-goals / follow-ups +- Android first-run guided screen (Android already has `AccountBackupScreen` + + post-signup nudge; a matching stepper is a separate follow-up). +- Hard copy/verify enforcement (explicitly declined — soft gate). +- Mnemonic (NIP-06) backup. + +## Unanswered questions +- Add-account path: hoist to App-level full-screen (recommended) vs enlarge the + dialog to host the stepper? (Plan assumes hoist.) +- Does generating from the logged-in state and cancelling mid-onboarding need an + explicit "discard this new key" confirm, or is silent discard fine? +- Show the nsec revealed by default on Step 2 (it's first-run, user must save it) — + or masked-with-reveal like the Settings card? (Plan assumes shown, since the + whole point is to save it now.) +- Progress UI: numbered "Step X of 3" vs dots vs a top wizard bar? +- Keep the `NewKeyWarningCard` as a fallback anywhere, or fully delete? diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index b9d2f2f6ae..19cec16497 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -121,6 +121,7 @@ import com.vitorpamplona.amethyst.desktop.ui.LocalBlossomServers import com.vitorpamplona.amethyst.desktop.ui.LoginScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog +import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyOnboardingScreen import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopDmRoute import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker import com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawer @@ -1337,300 +1338,322 @@ private fun AppInner( LocalNotificationSettings provides notifSettings, LocalNotificationReadState provides notifReadState, ) { - when (accountState) { - is AccountState.Loading -> { - // Branded loading screen while accounts load from storage - val loadingIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(32.dp), - color = MaterialTheme.colorScheme.primary, - strokeWidth = 3.dp, - ) - Spacer(Modifier.height(16.dp)) - Text( - "Amethyst", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - Spacer(Modifier.height(24.dp)) - androidx.compose.material3.Icon( - painter = loadingIcon, - contentDescription = "Amethyst", - modifier = Modifier.size(96.dp), - tint = MaterialTheme.colorScheme.primary, - ) + val pendingNewAccount by accountManager.pendingNewAccount.collectAsState() + val pendingNew = pendingNewAccount + if (pendingNew != null) { + // First-run "save your keys" onboarding for a freshly generated + // account. Rendered above the account-state switch so it survives + // until the user finishes (which activates + persists the account). + NewKeyOnboardingScreen( + npub = pendingNew.npub, + nsec = pendingNew.nsec, + onFinish = { + scope.launch(Dispatchers.IO) { + accountManager.ensureCurrentAccountInStorage() + accountManager.finishNewAccountOnboarding() + accountManager.saveCurrentAccount() + accountManager.ensureCurrentAccountInStorage() + accountManager.refreshAccountList() } - } - } - - is AccountState.LoggedOut -> { - LoginScreen( - accountManager = accountManager, - onLoginSuccess = { - // Start heartbeat if bunker account - val current = accountManager.currentAccount() - if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) { - accountManager.startHeartbeat(scope) - } - // Save account (privkey to keychain + metadata to disk) - // then ensure multi-account storage is up to date. - // Uses App-level scope so it survives LoginScreen leaving composition. - scope.launch(Dispatchers.IO) { - accountManager.saveCurrentAccount() - accountManager.ensureCurrentAccountInStorage() - accountManager.refreshAccountList() - } - }, - ) - } - - is AccountState.ConnectingRelays -> { - val relays by relayManager.relayStatuses.collectAsState() - ConnectingRelaysScreen( - subtitle = "Restoring remote signer session", - relayStatuses = relays, - ) - } - - is AccountState.LoggedIn -> { - val account = accountState as AccountState.LoggedIn - val nwcConnection by accountManager.nwcConnection.collectAsState() - - // Account state holders (relay lists, blossom servers, DMs, WoT). - // Hoisted above MainContent so the top-level compose dialog can also - // read the account's blossom server list from iAccount directly. - val dmSendTracker = remember(relayManager) { DmSendTracker(relayManager.client) } - // Created before iAccount so NIP-65 backup can be loaded. - val accountRelays = - remember(account, relayManager, scope) { - DesktopAccountRelays(account.pubKeyHex, relayManager, scope) - } - // Cold-boot AUTH race fix: when the account's own kind:10050 DM-inbox - // set loads (often AFTER an inbox relay has already challenged for - // AUTH), retroactively auto-approve any pending tier-2 prompt for a - // relay that is actually tier-1, instead of leaving a spurious banner. - LaunchedEffect(authCoordinator, accountRelays) { - accountRelays.dmRelayList.collect { dmInbox -> - authCoordinator.onSelfApprovedRelaysChanged(dmInbox) - } - } - val iAccount = - remember(account, localCache, relayManager, dmSendTracker, accountRelays, dmInboxResolver) { - DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays, dmInboxResolver) - } - // When iAccount is replaced (account switch), close the previous - // WoTService so its writer coroutine + ops Channel don't leak. - DisposableEffect(iAccount) { - onDispose { iAccount.wotService.close() } - } - - // Lazy-load Namecoin services. The Core RPC HTTP - // client is sourced from the Tor-aware DesktopHttpClient - // singleton so .onion RPC URLs route through the - // user's Tor settings without extra plumbing. - val namecoinPreferences = remember { DesktopNamecoinPreferences() } - val namecoinService = - remember { - DesktopNamecoinNameService( - preferencesProvider = { namecoinPreferences.current }, - pinnedCertsProvider = { namecoinPreferences.loadPinnedCerts() }, - coreRpcHttpClientProvider = { _ -> - com.vitorpamplona.amethyst.desktop.network - .DesktopHttpClient - .currentClient() - }, - ) - } - - // NWC loaded during startup in loadSavedAccount flow - - val currentTorStatus = torManager.status.collectAsState().value - val followedUsers by localCache.followedUsers.collectAsState() - val spamExemptKeys = - remember(followedUsers, account.pubKeyHex) { - followedUsers + account.pubKeyHex - } - androidx.compose.runtime.CompositionLocalProvider( - com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides - com.vitorpamplona.amethyst.desktop.ui.tor.TorState( - status = currentTorStatus, - settings = torSettings, - onSettingsChanged = { newSettings -> - torSettings = newSettings - com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences - .save(newSettings) - torTypeFlow.value = newSettings.torType - externalPortFlow.value = newSettings.externalSocksPort - // Rebuild app to apply Tor changes - onRestartApp() - }, - ), - LocalNamecoinPreferences provides namecoinPreferences, - LocalNamecoinService provides namecoinService, - LocalSpamExemptKeys provides spamExemptKeys, - com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount, - LocalUserFinder provides subscriptionsCoordinator.userFinder, - LocalUserFinderAccount provides iAccount, - LocalEventFinder provides subscriptionsCoordinator.eventFinder, - ) { - val pendingAuthApprovals by authCoordinator.pendingApprovals.collectAsState() - Column(modifier = Modifier.fillMaxSize()) { - // On macOS the window uses `apple.awt.fullWindowContent` - // (see [applyNativeWindowChrome]), so the traffic-light - // buttons sit over the top-left corner of content. Clear - // that zone so the banner text/icon aren't occluded. - val bannerModifier = - if (PlatformInfo.isMacOS) { - Modifier.padding(start = 80.dp, top = 8.dp, end = 8.dp, bottom = 4.dp) - } else { - Modifier.padding(horizontal = 8.dp, vertical = 4.dp) - } - AuthApprovalBanner( - pending = pendingAuthApprovals.values.toList(), - onResolve = { url, scope -> authCoordinator.resolve(url, scope) }, - modifier = bannerModifier, - ) - Box(modifier = Modifier.weight(1f)) { - // Force a Compose subtree teardown when the active - // account changes. Without this, the currently-open - // column keeps its account-A `remember { ... }` - // state (LazyListState scroll position, expanded - // rows, filter-tab selection, in-flight metadata - // observers, per-column view-models) even though the - // outer `iAccount` / `accountRelays` swap correctly. - // Users saw account A's notifications / profile / - // messages page rendered under account B's identity - // until they navigated away and back. `key(pubKeyHex)` - // is the idiomatic Compose way to reset an entire - // subtree on identity change while keeping the outer - // deck layout / workspace state (declared above) alive. - androidx.compose.runtime.key(account.pubKeyHex) { - MainContent( - layoutMode = layoutMode, - deckState = deckState, - workspaceManager = workspaceManager, - singlePaneState = singlePaneState, - pinnedNavBarState = pinnedNavBarState, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - iAccount = iAccount, - accountRelays = accountRelays, - dmSendTracker = dmSendTracker, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - indexRelaysStore = indexRelaysStore, - nip11Fetcher = nip11Fetcher, - dmInboxResolver = dmInboxResolver, - appScope = scope, - torStatus = currentTorStatus, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onEditInComposer = onEditInComposer, - onShowAppDrawer = onShowAppDrawer, - onOpenFeedsDrawer = { - appDrawerInitialTab = - com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS - onShowAppDrawer() - }, - onShowImportFollowListDialog = onShowImportFollowListDialog, - ) - } - } - } - - // Import Follow List dialog (triggered from File menu / - // Cmd+Shift+I). Rendered inside this CompositionLocalProvider - // so LocalNamecoinService is available for .bit / d/ / id/ - // identifier resolution. - if (showImportFollowListDialog) { - ImportFollowListDialog( - onDismiss = onDismissImportFollowListDialog, - relayManager = relayManager, - account = account, - localCache = localCache, - ) - } - } - - // Compose dialog. Hosted outside MainContent's provider, - // so provide the account's blossom list here too. - if (showComposeDialog) { - CompositionLocalProvider( - LocalBlossomServers provides iAccount.blossomServerList.flow, + }, + onCancel = { accountManager.cancelNewAccountOnboarding() }, + ) + } else { + when (accountState) { + is AccountState.Loading -> { + // Branded loading screen while accounts load from storage + val loadingIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, ) { - ComposeNoteDialog( - onDismiss = onDismissComposeDialog, - relayManager = relayManager, - account = account, - localCache = localCache, - replyTo = replyToNote, - draftDTag = composeEditDraftTag, - draftInitialContent = composeEditContent, - initialScheduledForSec = composeEditScheduledForSec, - ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp, + ) + Spacer(Modifier.height(16.dp)) + Text( + "Amethyst", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(24.dp)) + androidx.compose.material3.Icon( + painter = loadingIcon, + contentDescription = "Amethyst", + modifier = Modifier.size(96.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } } } - // App Drawer overlay - if (showAppDrawer) { - val openColumns by deckState.columns.collectAsState() - AppDrawer( - initialTab = appDrawerInitialTab, - openColumnTypes = - if (layoutMode == LayoutMode.DECK) { - openColumns.map { it.type.typeKey() }.toSet() - } else { - emptySet() - }, - pinnedNavBarState = pinnedNavBarState, - workspaceManager = workspaceManager, - onSwitchWorkspace = { ws -> - // Switch layout mode to match workspace - onLayoutModeChange(ws.layoutMode) - // Load columns or single pane screen - when (ws.layoutMode) { - LayoutMode.DECK -> { - deckState.loadFromWorkspace(ws.columns) - } - - LayoutMode.SINGLE_PANE -> { - // Load nav bar from workspace + navigate to first screen - pinnedNavBarState.loadFromWorkspace() - val firstKey = - ws.singlePaneScreens.firstOrNull() ?: "home" - val type = DeckState.parseColumnTypeFromKey(firstKey) - if (type != null) singlePaneState.navigate(type) - } + is AccountState.LoggedOut -> { + LoginScreen( + accountManager = accountManager, + onLoginSuccess = { + // Start heartbeat if bunker account + val current = accountManager.currentAccount() + if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) { + accountManager.startHeartbeat(scope) } - }, - onSelectScreen = { type -> - when (layoutMode) { - LayoutMode.DECK -> { - if (deckState.hasColumnOfType(type)) { - deckState.focusExistingColumn(type) - } else { - deckState.addColumn(type) - } - } - - LayoutMode.SINGLE_PANE -> { - singlePaneState.navigate(type) - } + // Save account (privkey to keychain + metadata to disk) + // then ensure multi-account storage is up to date. + // Uses App-level scope so it survives LoginScreen leaving composition. + scope.launch(Dispatchers.IO) { + accountManager.saveCurrentAccount() + accountManager.ensureCurrentAccountInStorage() + accountManager.refreshAccountList() } }, - onDismiss = { - appDrawerInitialTab = null - onDismissAppDrawer() - }, ) } + + is AccountState.ConnectingRelays -> { + val relays by relayManager.relayStatuses.collectAsState() + ConnectingRelaysScreen( + subtitle = "Restoring remote signer session", + relayStatuses = relays, + ) + } + + is AccountState.LoggedIn -> { + val account = accountState as AccountState.LoggedIn + val nwcConnection by accountManager.nwcConnection.collectAsState() + + // Account state holders (relay lists, blossom servers, DMs, WoT). + // Hoisted above MainContent so the top-level compose dialog can also + // read the account's blossom server list from iAccount directly. + val dmSendTracker = remember(relayManager) { DmSendTracker(relayManager.client) } + // Created before iAccount so NIP-65 backup can be loaded. + val accountRelays = + remember(account, relayManager, scope) { + DesktopAccountRelays(account.pubKeyHex, relayManager, scope) + } + // Cold-boot AUTH race fix: when the account's own kind:10050 DM-inbox + // set loads (often AFTER an inbox relay has already challenged for + // AUTH), retroactively auto-approve any pending tier-2 prompt for a + // relay that is actually tier-1, instead of leaving a spurious banner. + LaunchedEffect(authCoordinator, accountRelays) { + accountRelays.dmRelayList.collect { dmInbox -> + authCoordinator.onSelfApprovedRelaysChanged(dmInbox) + } + } + val iAccount = + remember(account, localCache, relayManager, dmSendTracker, accountRelays, dmInboxResolver) { + DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays, dmInboxResolver) + } + // When iAccount is replaced (account switch), close the previous + // WoTService so its writer coroutine + ops Channel don't leak. + DisposableEffect(iAccount) { + onDispose { iAccount.wotService.close() } + } + + // Lazy-load Namecoin services. The Core RPC HTTP + // client is sourced from the Tor-aware DesktopHttpClient + // singleton so .onion RPC URLs route through the + // user's Tor settings without extra plumbing. + val namecoinPreferences = remember { DesktopNamecoinPreferences() } + val namecoinService = + remember { + DesktopNamecoinNameService( + preferencesProvider = { namecoinPreferences.current }, + pinnedCertsProvider = { namecoinPreferences.loadPinnedCerts() }, + coreRpcHttpClientProvider = { _ -> + com.vitorpamplona.amethyst.desktop.network + .DesktopHttpClient + .currentClient() + }, + ) + } + + // NWC loaded during startup in loadSavedAccount flow + + val currentTorStatus = torManager.status.collectAsState().value + val followedUsers by localCache.followedUsers.collectAsState() + val spamExemptKeys = + remember(followedUsers, account.pubKeyHex) { + followedUsers + account.pubKeyHex + } + androidx.compose.runtime.CompositionLocalProvider( + com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides + com.vitorpamplona.amethyst.desktop.ui.tor.TorState( + status = currentTorStatus, + settings = torSettings, + onSettingsChanged = { newSettings -> + torSettings = newSettings + com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences + .save(newSettings) + torTypeFlow.value = newSettings.torType + externalPortFlow.value = newSettings.externalSocksPort + // Rebuild app to apply Tor changes + onRestartApp() + }, + ), + LocalNamecoinPreferences provides namecoinPreferences, + LocalNamecoinService provides namecoinService, + LocalSpamExemptKeys provides spamExemptKeys, + com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount, + LocalUserFinder provides subscriptionsCoordinator.userFinder, + LocalUserFinderAccount provides iAccount, + LocalEventFinder provides subscriptionsCoordinator.eventFinder, + ) { + val pendingAuthApprovals by authCoordinator.pendingApprovals.collectAsState() + Column(modifier = Modifier.fillMaxSize()) { + // On macOS the window uses `apple.awt.fullWindowContent` + // (see [applyNativeWindowChrome]), so the traffic-light + // buttons sit over the top-left corner of content. Clear + // that zone so the banner text/icon aren't occluded. + val bannerModifier = + if (PlatformInfo.isMacOS) { + Modifier.padding(start = 80.dp, top = 8.dp, end = 8.dp, bottom = 4.dp) + } else { + Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + } + AuthApprovalBanner( + pending = pendingAuthApprovals.values.toList(), + onResolve = { url, scope -> authCoordinator.resolve(url, scope) }, + modifier = bannerModifier, + ) + Box(modifier = Modifier.weight(1f)) { + // Force a Compose subtree teardown when the active + // account changes. Without this, the currently-open + // column keeps its account-A `remember { ... }` + // state (LazyListState scroll position, expanded + // rows, filter-tab selection, in-flight metadata + // observers, per-column view-models) even though the + // outer `iAccount` / `accountRelays` swap correctly. + // Users saw account A's notifications / profile / + // messages page rendered under account B's identity + // until they navigated away and back. `key(pubKeyHex)` + // is the idiomatic Compose way to reset an entire + // subtree on identity change while keeping the outer + // deck layout / workspace state (declared above) alive. + androidx.compose.runtime.key(account.pubKeyHex) { + MainContent( + layoutMode = layoutMode, + deckState = deckState, + workspaceManager = workspaceManager, + singlePaneState = singlePaneState, + pinnedNavBarState = pinnedNavBarState, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + iAccount = iAccount, + accountRelays = accountRelays, + dmSendTracker = dmSendTracker, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + indexRelaysStore = indexRelaysStore, + nip11Fetcher = nip11Fetcher, + dmInboxResolver = dmInboxResolver, + appScope = scope, + torStatus = currentTorStatus, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onEditInComposer = onEditInComposer, + onShowAppDrawer = onShowAppDrawer, + onOpenFeedsDrawer = { + appDrawerInitialTab = + com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS + onShowAppDrawer() + }, + onShowImportFollowListDialog = onShowImportFollowListDialog, + ) + } + } + } + + // Import Follow List dialog (triggered from File menu / + // Cmd+Shift+I). Rendered inside this CompositionLocalProvider + // so LocalNamecoinService is available for .bit / d/ / id/ + // identifier resolution. + if (showImportFollowListDialog) { + ImportFollowListDialog( + onDismiss = onDismissImportFollowListDialog, + relayManager = relayManager, + account = account, + localCache = localCache, + ) + } + } + + // Compose dialog. Hosted outside MainContent's provider, + // so provide the account's blossom list here too. + if (showComposeDialog) { + CompositionLocalProvider( + LocalBlossomServers provides iAccount.blossomServerList.flow, + ) { + ComposeNoteDialog( + onDismiss = onDismissComposeDialog, + relayManager = relayManager, + account = account, + localCache = localCache, + replyTo = replyToNote, + draftDTag = composeEditDraftTag, + draftInitialContent = composeEditContent, + initialScheduledForSec = composeEditScheduledForSec, + ) + } + } + + // App Drawer overlay + if (showAppDrawer) { + val openColumns by deckState.columns.collectAsState() + AppDrawer( + initialTab = appDrawerInitialTab, + openColumnTypes = + if (layoutMode == LayoutMode.DECK) { + openColumns.map { it.type.typeKey() }.toSet() + } else { + emptySet() + }, + pinnedNavBarState = pinnedNavBarState, + workspaceManager = workspaceManager, + onSwitchWorkspace = { ws -> + // Switch layout mode to match workspace + onLayoutModeChange(ws.layoutMode) + // Load columns or single pane screen + when (ws.layoutMode) { + LayoutMode.DECK -> { + deckState.loadFromWorkspace(ws.columns) + } + + LayoutMode.SINGLE_PANE -> { + // Load nav bar from workspace + navigate to first screen + pinnedNavBarState.loadFromWorkspace() + val firstKey = + ws.singlePaneScreens.firstOrNull() ?: "home" + val type = DeckState.parseColumnTypeFromKey(firstKey) + if (type != null) singlePaneState.navigate(type) + } + } + }, + onSelectScreen = { type -> + when (layoutMode) { + LayoutMode.DECK -> { + if (deckState.hasColumnOfType(type)) { + deckState.focusExistingColumn(type) + } else { + deckState.addColumn(type) + } + } + + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(type) + } + } + }, + onDismiss = { + appDrawerInitialTab = null + onDismissAppDrawer() + }, + ) + } + } } } @@ -2353,10 +2376,6 @@ fun ProfileScreen( isReadOnly = account.isReadOnly, ) - Spacer(Modifier.height(16.dp)) - - BackupKeysCard(account = account) - Spacer(Modifier.height(24.dp)) OutlinedButton( @@ -2422,6 +2441,11 @@ fun RelaySettingsScreen( Spacer(Modifier.height(16.dp)) + // Account Keys / Backup Section + BackupKeysCard(account = account) + + Spacer(Modifier.height(24.dp)) + // Wallet Connect Section Text( "Wallet Connect (NWC)", diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 6eb0354925..88c1a30aff 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -554,20 +554,66 @@ class AccountManager internal constructor( return Result.success(Unit) } - fun generateNewAccount(): AccountState.LoggedIn { + /** + * Builds a fresh keypair account WITHOUT activating it — leaves [_accountState] + * untouched so the caller can show a "save your keys" backup step first. Call + * [activateAccount] once the user has acknowledged the backup. + * + * Flipping the account state immediately (as [generateNewAccount] does) tears + * down whatever screen triggered generation — the login screen or the add-account + * dialog — before the backup card can render, which is why generation and + * activation are split here. + */ + fun buildNewAccount(): AccountState.LoggedIn { val keyPair = KeyPair() val signer = NostrSignerInternal(keyPair) - val state = - AccountState.LoggedIn( - signer = signer, - pubKeyHex = keyPair.pubKey.toHexKey(), - npub = keyPair.pubKey.toNpub(), - nsec = keyPair.privKey?.toNsec(), - isReadOnly = false, - ) + return AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = keyPair.privKey?.toNsec(), + isReadOnly = false, + ) + } + + /** Activates a previously-[buildNewAccount]-ed (or any) state as the current account. */ + fun activateAccount(state: AccountState.LoggedIn) { _accountState.value = state - return state + } + + fun generateNewAccount(): AccountState.LoggedIn = buildNewAccount().also { _accountState.value = it } + + // --- First-run key-backup onboarding --- + // + // A freshly-generated account is held here (NOT activated) while the user is + // walked through the "save your keys" onboarding screen. Activation is deferred + // to [finishNewAccountOnboarding] so the onboarding UI can render before the + // account-state flip swaps the current screen out. + + private val _pendingNewAccount = MutableStateFlow(null) + val pendingNewAccount: StateFlow = _pendingNewAccount.asStateFlow() + + /** Begins onboarding: builds a fresh key WITHOUT activating it. */ + fun beginNewAccountOnboarding() { + _pendingNewAccount.value = buildNewAccount() + } + + /** User backed out of onboarding — discard the un-activated key. */ + fun cancelNewAccountOnboarding() { + _pendingNewAccount.value = null + } + + /** + * User finished onboarding — activate the pending account and clear it. + * Returns the now-active account, or null if there was none pending. + * The caller is responsible for persistence ([saveCurrentAccount] etc.). + */ + fun finishNewAccountOnboarding(): AccountState.LoggedIn? { + val pending = _pendingNewAccount.value ?: return null + _accountState.value = pending + _pendingNewAccount.value = null + return pending } fun loginWithKey(keyInput: String): Result { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt index 60578f67b3..c2b2e7d1aa 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -39,9 +39,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -52,10 +49,8 @@ import com.vitorpamplona.amethyst.commons.resources.login_subtitle_desktop import com.vitorpamplona.amethyst.commons.resources.login_title import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen import com.vitorpamplona.amethyst.desktop.account.AccountManager -import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.RelayStatus import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard -import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyWarningCard import org.jetbrains.compose.resources.stringResource @Composable @@ -63,9 +58,6 @@ fun LoginScreen( accountManager: AccountManager, onLoginSuccess: () -> Unit, ) { - var showNewKeyDialog by remember { mutableStateOf(false) } - var generatedAccount by remember { mutableStateOf(null) } - val loginProgress by accountManager.loginProgress.collectAsState() val keychainUnavailable by accountManager.keychainUnavailable.collectAsState() @@ -108,8 +100,10 @@ fun LoginScreen( } }, onGenerateNew = { - generatedAccount = accountManager.generateNewAccount() - showNewKeyDialog = true + // Hand off to the first-run onboarding screen (hoisted above the + // account-state switch in Main): builds the key, walks the user + // through backup, then activates + persists on finish. + accountManager.beginNewAccountOnboarding() }, onLoginBunker = { bunkerUri -> accountManager.loginWithBunker(bunkerUri).map { @@ -125,19 +119,6 @@ fun LoginScreen( }, loginProgress = loginProgress, ) - - val account = generatedAccount - if (showNewKeyDialog && account != null) { - Spacer(Modifier.height(24.dp)) - NewKeyWarningCard( - npub = account.npub, - nsec = account.nsec, - onContinue = { - showNewKeyDialog = false - onLoginSuccess() - }, - ) - } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AddAccountDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AddAccountDialog.kt index 5df79f8031..f893f82597 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AddAccountDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AddAccountDialog.kt @@ -90,16 +90,11 @@ fun AddAccountDialog( Result.success(Unit) }, onGenerateNew = { - scope.launch { - withContext(Dispatchers.IO) { - accountManager.ensureCurrentAccountInStorage() - } - accountManager.generateNewAccount() - withContext(Dispatchers.IO) { - accountManager.saveCurrentAccount() - } - onAccountAdded() - } + // Hand off to the first-run onboarding screen (hoisted above the + // account-state switch in Main). It builds the key, walks the user + // through backup, then activates + persists on finish. + accountManager.beginNewAccountOnboarding() + onDismiss() }, onLoginBunker = { bunkerUri -> accountManager.ensureCurrentAccountInStorage() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyOnboardingScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyOnboardingScreen.kt new file mode 100644 index 0000000000..ea0d611615 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyOnboardingScreen.kt @@ -0,0 +1,647 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.auth + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.action_copy +import com.vitorpamplona.amethyst.commons.resources.backup_keys_copied +import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy_plain_warning +import com.vitorpamplona.amethyst.commons.resources.backup_keys_encrypt_failed +import com.vitorpamplona.amethyst.commons.resources.backup_keys_hide_qr +import com.vitorpamplona.amethyst.commons.resources.backup_keys_show_qr +import com.vitorpamplona.amethyst.commons.resources.new_key_back +import com.vitorpamplona.amethyst.commons.resources.new_key_cancel +import com.vitorpamplona.amethyst.commons.resources.new_key_confirm_recap +import com.vitorpamplona.amethyst.commons.resources.new_key_confirm_title +import com.vitorpamplona.amethyst.commons.resources.new_key_continue_button +import com.vitorpamplona.amethyst.commons.resources.new_key_copy_encrypted_button +import com.vitorpamplona.amethyst.commons.resources.new_key_encrypt_password_label +import com.vitorpamplona.amethyst.commons.resources.new_key_keys_title +import com.vitorpamplona.amethyst.commons.resources.new_key_next +import com.vitorpamplona.amethyst.commons.resources.new_key_public_label +import com.vitorpamplona.amethyst.commons.resources.new_key_readonly_info +import com.vitorpamplona.amethyst.commons.resources.new_key_saved_checkbox +import com.vitorpamplona.amethyst.commons.resources.new_key_secret_label +import com.vitorpamplona.amethyst.commons.resources.new_key_step_indicator +import com.vitorpamplona.amethyst.commons.resources.new_key_step_intro_npub +import com.vitorpamplona.amethyst.commons.resources.new_key_step_intro_nsec +import com.vitorpamplona.amethyst.commons.resources.new_key_step_intro_title +import com.vitorpamplona.amethyst.commons.resources.new_key_warning_message +import com.vitorpamplona.amethyst.desktop.util.copyToClipboard +import com.vitorpamplona.amethyst.desktop.util.copyToClipboardThenClear +import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull +import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.stringResource + +private const val TOTAL_STEPS = 3 + +/** + * Full-window, three-step onboarding shown ONCE right after a new Nostr keypair + * is generated. This is the only moment the plaintext nsec is displayed. + * + * - Step 0 explains why the keys matter (npub is shareable, nsec can never be reset). + * - Step 1 shows both keys: the npub with a plain copy + QR toggle, and the nsec + * with a prominent auto-clearing plaintext copy plus a de-emphasised NIP-49 + * encrypted copy. The nsec is NEVER rendered as a QR code. + * - Step 2 asks the user to confirm they saved the keys before proceeding. + * + * The whole thing is scrollable so nothing clips regardless of window size. + * + * @param npub The public key in npub format (shareable) + * @param nsec The secret key in nsec format, or null for a read-only account + * @param onFinish Called when the user acknowledged and wants to enter the app + * @param onCancel Called when the user backs out; the new key should be discarded + * @param modifier Modifier applied to the root container + */ +@Composable +fun NewKeyOnboardingScreen( + npub: String, + nsec: String?, + onFinish: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + var step by remember { mutableStateOf(0) } + + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = + Modifier + .widthIn(max = 560.dp) + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + StepIndicator(step = step) + + Spacer(Modifier.height(24.dp)) + + when (step) { + 0 -> + IntroStep( + onCancel = onCancel, + onNext = { step = 1 }, + ) + 1 -> + KeysStep( + npub = npub, + nsec = nsec, + onBack = { step = 0 }, + onNext = { step = 2 }, + ) + else -> + ConfirmStep( + onBack = { step = 1 }, + onFinish = onFinish, + ) + } + } + } +} + +@Composable +private fun StepIndicator(step: Int) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + repeat(TOTAL_STEPS) { index -> + val color = + if (index <= step) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } + Box( + modifier = + Modifier + .size(10.dp) + .clip(CircleShape) + .background(color), + ) + } + } + Spacer(Modifier.height(8.dp)) + Text( + stringResource(Res.string.new_key_step_indicator, step + 1, TOTAL_STEPS), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun IntroStep( + onCancel: () -> Unit, + onNext: () -> Unit, +) { + StepScaffold { + Icon( + symbol = MaterialSymbols.Warning, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.error, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + stringResource(Res.string.new_key_step_intro_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + stringResource(Res.string.new_key_warning_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + FramingRow( + symbol = MaterialSymbols.Info, + tint = MaterialTheme.colorScheme.primary, + text = stringResource(Res.string.new_key_step_intro_npub), + ) + + Spacer(Modifier.height(12.dp)) + + FramingRow( + symbol = MaterialSymbols.Key, + tint = MaterialTheme.colorScheme.error, + text = stringResource(Res.string.new_key_step_intro_nsec), + ) + + Spacer(Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = onCancel, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(Res.string.new_key_cancel)) + } + Button( + onClick = onNext, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(Res.string.new_key_next)) + } + } + } +} + +@Composable +private fun KeysStep( + npub: String, + nsec: String?, + onBack: () -> Unit, + onNext: () -> Unit, +) { + StepScaffold { + Text( + stringResource(Res.string.new_key_keys_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(24.dp)) + + PublicKeySection(npub = npub) + + Spacer(Modifier.height(24.dp)) + + if (nsec != null) { + SecretKeySection(nsec = nsec) + } else { + FramingRow( + symbol = MaterialSymbols.Info, + tint = MaterialTheme.colorScheme.primary, + text = stringResource(Res.string.new_key_readonly_info), + ) + } + + Spacer(Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = onBack, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(Res.string.new_key_back)) + } + Button( + onClick = onNext, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(Res.string.new_key_next)) + } + } + } +} + +@Composable +private fun PublicKeySection(npub: String) { + var showQr by remember { mutableStateOf(false) } + + Text( + stringResource(Res.string.new_key_public_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + SelectableKeyText(npub) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CopyButton( + symbol = MaterialSymbols.ContentCopy, + idleLabel = stringResource(Res.string.action_copy), + onCopy = { copyToClipboard(npub) }, + ) + OutlinedButton(onClick = { showQr = !showQr }) { + Icon( + symbol = MaterialSymbols.QrCode2, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text( + if (showQr) { + stringResource(Res.string.backup_keys_hide_qr) + } else { + stringResource(Res.string.backup_keys_show_qr) + }, + ) + } + } + if (showQr) { + Spacer(Modifier.height(12.dp)) + QrCodeCanvas(data = npub) + } +} + +@Composable +private fun SecretKeySection(nsec: String) { + val scope = rememberCoroutineScope() + + Text( + stringResource(Res.string.new_key_secret_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(4.dp)) + SelectableKeyText(nsec) + Spacer(Modifier.height(8.dp)) + + CopyButton( + symbol = MaterialSymbols.Key, + idleLabel = stringResource(Res.string.action_copy), + primary = true, + onCopy = { copyToClipboardThenClear(nsec, scope, delayMs = 60_000L) }, + ) + + Spacer(Modifier.height(8.dp)) + + FramingRow( + symbol = MaterialSymbols.Warning, + tint = MaterialTheme.colorScheme.error, + text = stringResource(Res.string.backup_keys_copy_plain_warning), + ) + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + + EncryptedCopySection(nsec = nsec) +} + +@Composable +private fun EncryptedCopySection(nsec: String) { + var password by remember { mutableStateOf("") } + var showChars by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(false) } + var copied by remember { mutableStateOf(false) } + var working by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + OutlinedTextField( + value = password, + onValueChange = { + password = it + error = false + }, + label = { Text(stringResource(Res.string.new_key_encrypt_password_label)) }, + singleLine = true, + isError = error, + supportingText = + if (error) { + { Text(stringResource(Res.string.backup_keys_encrypt_failed)) } + } else { + null + }, + visualTransformation = + if (showChars) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { showChars = !showChars }) { + Icon( + symbol = if (showChars) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility, + contentDescription = null, + ) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { + error = false + working = true + scope.launch { + val encrypted = + withContext(Dispatchers.Default) { + decodePrivateKeyAsHexOrNull(nsec)?.let { + runCatching { Nip49().encrypt(it, password) }.getOrNull() + } + } + working = false + if (encrypted != null) { + copyToClipboard(encrypted) + copied = true + } else { + error = true + } + } + }, + enabled = password.isNotBlank() && !working, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text( + if (copied) { + stringResource(Res.string.backup_keys_copied) + } else { + stringResource(Res.string.new_key_copy_encrypted_button) + }, + ) + } + + ResetCopiedAfterDelay(copied) { copied = false } +} + +@Composable +private fun ConfirmStep( + onBack: () -> Unit, + onFinish: () -> Unit, +) { + var acknowledged by remember { mutableStateOf(false) } + + StepScaffold { + Icon( + symbol = MaterialSymbols.Check, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + stringResource(Res.string.new_key_confirm_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + stringResource(Res.string.new_key_confirm_recap), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Checkbox( + checked = acknowledged, + onCheckedChange = { acknowledged = it }, + ) + Text( + stringResource(Res.string.new_key_saved_checkbox), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = onBack, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(Res.string.new_key_back)) + } + Button( + onClick = onFinish, + enabled = acknowledged, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(Res.string.new_key_continue_button)) + } + } + } +} + +/** Shared per-step body: a card whose content scrolls so it never clips. */ +@Composable +private fun StepScaffold(content: @Composable ColumnScope.() -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + content = content, + ) + } +} + +/** A small icon + explainer text row used to frame npub/nsec and warnings. */ +@Composable +private fun FramingRow( + symbol: MaterialSymbol, + tint: Color, + text: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + tint = tint, + ) + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +/** Copy button that flashes a transient "Copied!" for ~2s after activation. */ +@Composable +private fun CopyButton( + symbol: MaterialSymbol, + idleLabel: String, + primary: Boolean = false, + onCopy: () -> Unit, +) { + var copied by remember { mutableStateOf(false) } + val label = + if (copied) stringResource(Res.string.backup_keys_copied) else idleLabel + val onClick: () -> Unit = { + onCopy() + copied = true + } + + if (primary) { + Button(onClick = onClick) { + Icon(symbol = symbol, contentDescription = null, modifier = Modifier.padding(end = 4.dp)) + Text(label) + } + } else { + OutlinedButton(onClick = onClick) { + Icon(symbol = symbol, contentDescription = null, modifier = Modifier.padding(end = 4.dp)) + Text(label) + } + } + + ResetCopiedAfterDelay(copied) { copied = false } +} + +@Composable +private fun ResetCopiedAfterDelay( + copied: Boolean, + onReset: () -> Unit, +) { + if (copied) { + LaunchedEffect(Unit) { + delay(2000) + onReset() + } + } +} + +@Preview +@Composable +fun NewKeyOnboardingScreenPreview() { + NewKeyOnboardingScreen( + npub = "npub1example1234567890abcdefghijklmnopqrstuvwxyz1234567890", + nsec = "nsec1example1234567890abcdefghijklmnopqrstuvwxyz1234567890", + onFinish = {}, + onCancel = {}, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt deleted file mode 100644 index 409ddeb3ef..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt +++ /dev/null @@ -1,296 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.desktop.ui.auth - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.action_copy -import com.vitorpamplona.amethyst.commons.resources.backup_keys_copied -import com.vitorpamplona.amethyst.commons.resources.backup_keys_encrypt_failed -import com.vitorpamplona.amethyst.commons.resources.new_key_continue_button -import com.vitorpamplona.amethyst.commons.resources.new_key_copy_encrypted_button -import com.vitorpamplona.amethyst.commons.resources.new_key_encrypt_password_label -import com.vitorpamplona.amethyst.commons.resources.new_key_public_label -import com.vitorpamplona.amethyst.commons.resources.new_key_saved_checkbox -import com.vitorpamplona.amethyst.commons.resources.new_key_secret_label -import com.vitorpamplona.amethyst.commons.resources.new_key_warning_message -import com.vitorpamplona.amethyst.commons.resources.new_key_warning_title -import com.vitorpamplona.amethyst.desktop.util.copyToClipboard -import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull -import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 -import kotlinx.coroutines.delay -import org.jetbrains.compose.resources.stringResource - -/** - * Warning card displayed after generating a new Nostr key pair. - * Reminds users to save their keys and shows both public and secret keys. - * - * The npub is shareable (plain + copy). The nsec is an unrecoverable password: - * it is shown so the user can save it, offers plaintext AND NIP-49 encrypted - * copy, and is never rendered as a QR code. A soft acknowledgement checkbox - * nudges the user to confirm they saved their keys before continuing. - * - * @param npub The public key in npub format - * @param nsec The secret key in nsec format (nullable for read-only accounts) - * @param onContinue Callback when user acknowledges they've saved their keys - * @param modifier Modifier for the card - * @param cardWidth Width of the card (default 500.dp) - */ -@Composable -fun NewKeyWarningCard( - npub: String, - nsec: String?, - onContinue: () -> Unit, - modifier: Modifier = Modifier, - cardWidth: Dp = 500.dp, -) { - var acknowledged by remember { mutableStateOf(false) } - - Card( - modifier = modifier.width(cardWidth), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), - ), - ) { - Column( - modifier = Modifier.padding(24.dp), - ) { - Text( - stringResource(Res.string.new_key_warning_title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - - Spacer(Modifier.height(16.dp)) - - Text( - stringResource(Res.string.new_key_warning_message), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - - Spacer(Modifier.height(16.dp)) - - Text( - stringResource(Res.string.new_key_public_label), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - SelectableKeyText(npub) - Spacer(Modifier.height(8.dp)) - CopyKeyButton(value = npub) - - Spacer(Modifier.height(12.dp)) - - nsec?.let { secretKey -> - Text( - stringResource(Res.string.new_key_secret_label), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.error, - ) - SelectableKeyText(secretKey) - Spacer(Modifier.height(8.dp)) - CopyKeyButton(value = secretKey) - - Spacer(Modifier.height(16.dp)) - EncryptedCopyRow(nsec = secretKey) - } - - Spacer(Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Checkbox( - checked = acknowledged, - onCheckedChange = { acknowledged = it }, - ) - Text( - stringResource(Res.string.new_key_saved_checkbox), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - - Spacer(Modifier.height(16.dp)) - - Button( - onClick = onContinue, - enabled = acknowledged, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(Res.string.new_key_continue_button)) - } - } - } -} - -@Composable -private fun CopyKeyButton(value: String) { - var copied by remember { mutableStateOf(false) } - - OutlinedButton( - onClick = { - copyToClipboard(value) - copied = true - }, - ) { - Icon( - symbol = MaterialSymbols.ContentCopy, - contentDescription = null, - modifier = Modifier.padding(end = 4.dp), - ) - Text( - if (copied) { - stringResource(Res.string.backup_keys_copied) - } else { - stringResource(Res.string.action_copy) - }, - ) - } - - if (copied) { - LaunchedEffect(Unit) { - delay(2000) - copied = false - } - } -} - -@Composable -private fun EncryptedCopyRow(nsec: String) { - var password by remember { mutableStateOf("") } - var showChars by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(false) } - var copied by remember { mutableStateOf(false) } - - OutlinedTextField( - value = password, - onValueChange = { - password = it - error = false - }, - label = { Text(stringResource(Res.string.new_key_encrypt_password_label)) }, - singleLine = true, - isError = error, - supportingText = - if (error) { - { Text(stringResource(Res.string.backup_keys_encrypt_failed)) } - } else { - null - }, - visualTransformation = - if (showChars) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - Icon( - symbol = if (showChars) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - ) - }, - modifier = Modifier.fillMaxWidth(), - ) - - Spacer(Modifier.height(8.dp)) - - Button( - onClick = { - val hex = decodePrivateKeyAsHexOrNull(nsec) - val encrypted = - hex?.let { runCatching { Nip49().encrypt(it, password) }.getOrNull() } - if (encrypted != null) { - copyToClipboard(encrypted) - copied = true - } else { - error = true - } - }, - enabled = password.isNotBlank(), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - ), - ) { - Icon( - symbol = MaterialSymbols.Key, - contentDescription = null, - modifier = Modifier.padding(end = 4.dp), - ) - Text( - if (copied) { - stringResource(Res.string.backup_keys_copied) - } else { - stringResource(Res.string.new_key_copy_encrypted_button) - }, - ) - } - - if (copied) { - LaunchedEffect(Unit) { - delay(2000) - copied = false - } - } -} - -@Preview -@Composable -fun NewKeyWarningCardPreview() { - NewKeyWarningCard( - npub = "npub1example1234567890abcdefghijklmnopqrstuvwxyz", - nsec = "nsec1example1234567890abcdefghijklmnopqrstuvwxyz", - onContinue = {}, - ) -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt index 8d1c27f4bd..13e8e650b0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt @@ -31,10 +31,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -90,7 +90,10 @@ import com.vitorpamplona.amethyst.desktop.util.copyToClipboard import com.vitorpamplona.amethyst.desktop.util.copyToClipboardThenClear import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.stringResource /** @@ -348,6 +351,8 @@ private fun EncryptedCopy(nsec: String) { var showChars by remember { mutableStateOf(false) } var error by remember { mutableStateOf(false) } var copied by remember { mutableStateOf(false) } + var working by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() OutlinedTextField( value = password, @@ -367,37 +372,44 @@ private fun EncryptedCopy(nsec: String) { visualTransformation = if (showChars) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { - Icon( - symbol = if (showChars) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - ) + IconButton(onClick = { showChars = !showChars }) { + Icon( + symbol = if (showChars) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility, + contentDescription = null, + ) + } }, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(8.dp)) + // Same treatment as the plain Copy button. Encryption (scrypt) runs off the + // UI thread so the button stays responsive and reliably flips to "Copied!". Button( onClick = { - val hex = decodePrivateKeyAsHexOrNull(nsec) - val encrypted = - hex?.let { runCatching { Nip49().encrypt(it, password) }.getOrNull() } - if (encrypted != null) { - copyToClipboard(encrypted) - copied = true - } else { - error = true + error = false + working = true + scope.launch { + val encrypted = + withContext(Dispatchers.Default) { + decodePrivateKeyAsHexOrNull(nsec)?.let { + runCatching { Nip49().encrypt(it, password) }.getOrNull() + } + } + working = false + if (encrypted != null) { + copyToClipboard(encrypted) + copied = true + } else { + error = true + } } }, - enabled = password.isNotBlank(), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - ), + enabled = password.isNotBlank() && !working, ) { Icon( - symbol = MaterialSymbols.Key, + symbol = MaterialSymbols.ContentCopy, contentDescription = null, modifier = Modifier.padding(end = 4.dp), ) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/EncryptedKeyBackupTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/EncryptedKeyBackupTest.kt new file mode 100644 index 0000000000..7a44c3e3df --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/EncryptedKeyBackupTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.account + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Verifies the exact key-backup glue the UI uses: encode a generated key to an + * `nsec`, decode it back to hex, NIP-49-encrypt it to `ncryptsec1…`, and prove + * the encrypted blob decrypts back to the original key with the right password + * (and fails with the wrong one). This is the "how do I restore my encrypted + * backup" contract — the encrypt path is dead weight if it can't round-trip. + */ +class EncryptedKeyBackupTest { + @Test + fun encryptedBackupRoundTrips() { + val keyPair = KeyPair() + val nsec = keyPair.privKey!!.toNsec() + val password = "correct horse battery staple" + + // Same calls the UI makes: nsec -> hex -> Nip49 encrypt. + val hex = decodePrivateKeyAsHexOrNull(nsec) + assertNotNull(hex, "nsec should decode to hex") + + val ncryptsec = Nip49().encrypt(hex, password) + assertTrue(ncryptsec.startsWith("ncryptsec1"), "expected ncryptsec1 prefix, got: $ncryptsec") + + // Restore path: decrypt with the correct password recovers the original key. + val decryptedHex = Nip49().decrypt(ncryptsec, password) + assertEquals(hex, decryptedHex, "decrypt must recover the original private key") + assertEquals(keyPair.pubKey.toHexKey(), KeyPair(privKey = decryptedHex.hexToByteArray()).pubKey.toHexKey()) + } + + @Test + fun wrongPasswordFails() { + val keyPair = KeyPair() + val hex = decodePrivateKeyAsHexOrNull(keyPair.privKey!!.toNsec())!! + val ncryptsec = Nip49().encrypt(hex, "the right password") + + assertFailsWith { + Nip49().decrypt(ncryptsec, "the WRONG password") + } + } +}