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")
+ }
+ }
+}