From 60b7da6312123b7f65996de946a4413960670d4e Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 28 Jul 2026 15:33:47 -0700 Subject: [PATCH] feat(onboarding): rework backup step into masked-key chooser with copy and download options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BackupStep.tsx: replace the single Keycase card with a chooser view centered on a full-width masked key display: - Masked nsec with same-length monospace mask, inline reveal toggle (backup-key-reveal-toggle) and inline copy button (backup-copy-key) with spinner/checkmark feedback and an inline error banner (backup-copy-error) when the keychain fetch fails - Copy guidance ("save it somewhere safe — a password manager is a great place for it") moved into a tooltip on the copy icon - Security note restored below the key card - Encrypted download flow moved behind a footer CTA (backup-option-download) between Next and Back; opens a dedicated view with an "All backup options" back link (backup-back-to-options) - Raw key is fetched from the keychain only on explicit reveal/copy; the download path never fetches it - EncryptedBackupCreator.tsx: de-brand "Keycase" to download/password terminology across headings, buttons, errors, and a11y labels ("Create Keycase" → "Encrypt and download", "Keycase saved to" → "Saved to", etc.) - NsecMaskedDisplay.tsx: de-brand the ncryptsec kind label to "encrypted key" - onboarding-backup.spec.ts: rewrite for the new UX — chooser masked-key/reveal/copy coverage (with clipboard permissions), download happy path behind the footer CTA, return-to-chooser navigation, and reveal error/retry paths replacing the old raw-key error tests - onboarding-docked-cta-screenshots.spec.ts: capture the reveal toggle on the chooser instead of the removed backup-show-raw-key flow Settings keeps its own Keycase wording; only the onboarding surface is de-branded here. Signed-off-by: Taylor Ho Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/features/onboarding/ui/BackupStep.tsx | 323 ++++++++++++------ .../onboarding/ui/EncryptedBackupCreator.tsx | 42 +-- .../onboarding/ui/NsecMaskedDisplay.tsx | 6 +- desktop/tests/e2e/onboarding-backup.spec.ts | 161 +++++---- .../onboarding-docked-cta-screenshots.spec.ts | 9 +- 5 files changed, 333 insertions(+), 208 deletions(-) diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index aecc128dd..54c8f45cd 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -1,12 +1,14 @@ -import { AlertTriangle, Info, RefreshCw } from "lucide-react"; +import { ArrowLeft, Check, Copy, Eye, EyeOff, Info } from "lucide-react"; import * as React from "react"; import { getNsec } from "@/shared/api/tauriIdentity"; import { cn } from "@/shared/lib/cn"; +import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { Button } from "@/shared/ui/button"; import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo"; import { Card } from "@/shared/ui/card"; import { Spinner } from "@/shared/ui/spinner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; import { OnboardingFooter } from "./OnboardingFooter"; import { @@ -14,9 +16,7 @@ import { OnboardingSlideTransition, } from "./OnboardingSlideTransition"; import { EncryptedBackupCreator } from "./EncryptedBackupCreator"; -import { NsecMaskedDisplay } from "./NsecMaskedDisplay"; - -export type BackupStepMode = "encrypted" | "raw"; +import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay"; /** * How long the "Creating your identity key" loader holds the stage before the @@ -25,9 +25,44 @@ export type BackupStepMode = "encrypted" | "raw"; */ const INTRO_HOLD_MS = 1400; +/** + * The creation moment should only be sold once per app session. Module-level + * so remounts (e.g. navigating Back and returning to this step) skip the fake + * hold and show the finished state instantly. + */ +let introPlayed = false; + const REVEAL_ANIMATION_CLASS = "animate-in fade-in duration-700 motion-reduce:animate-none"; +/** Quicker fade for switching between the chooser and a backup method. */ +const VIEW_ANIMATION_CLASS = + "animate-in fade-in duration-300 motion-reduce:animate-none"; + +/** + * The chooser offers both backup methods: copying happens in place, while the + * encrypted download flow expands into its own view. + */ +type BackupView = "choose" | "download"; + +function BackToOptions({ onClick }: { onClick: () => void }) { + return ( +
+ +
+ ); +} + /** Saving a Keycase is recommended, never required to continue onboarding. */ export function backupNextDisabled(): boolean { return false; @@ -40,40 +75,33 @@ type BackupStepProps = { }; /** - * Onboarding backup step — recommends a portable Keycase without blocking - * setup. The raw key is fetched only after the user chooses the advanced path. + * Onboarding backup step — offers two ways to back up the new key without + * blocking setup: a portable password-protected Keycase (recommended) or a + * direct clipboard copy destined for a password manager. The raw key is + * fetched only when the user explicitly clicks Copy, goes straight to the + * clipboard, and is never rendered or held in state. */ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { - const [mode, setMode] = React.useState("encrypted"); + const [created, setCreated] = React.useState(introPlayed); + const [view, setView] = React.useState("choose"); + const [copyState, setCopyState] = React.useState< + "idle" | "copying" | "copied" + >("idle"); + const [copyError, setCopyError] = React.useState(null); const [nsec, setNsec] = React.useState(null); - const [isLoading, setIsLoading] = React.useState(false); - const [loadError, setLoadError] = React.useState(null); - const [created, setCreated] = React.useState(false); + const [isRevealed, setIsRevealed] = React.useState(false); const cancelledRef = React.useRef(false); + const copiedTimerRef = React.useRef(null); React.useEffect(() => { - const timer = window.setTimeout(() => setCreated(true), INTRO_HOLD_MS); + if (introPlayed) return; + const timer = window.setTimeout(() => { + introPlayed = true; + setCreated(true); + }, INTRO_HOLD_MS); return () => window.clearTimeout(timer); }, []); - const loadNsec = React.useCallback(async () => { - setIsLoading(true); - setLoadError(null); - try { - const value = await getNsec(); - if (!cancelledRef.current) setNsec(value); - } catch (err) { - if (!cancelledRef.current) - setLoadError( - err instanceof Error - ? err.message - : "Failed to retrieve private key.", - ); - } finally { - if (!cancelledRef.current) setIsLoading(false); - } - }, []); - React.useEffect(() => { cancelledRef.current = false; return () => { @@ -81,13 +109,61 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { // nsec from memory on unmount (backup step is only on the fresh-key path). cancelledRef.current = true; setNsec(null); + if (copiedTimerRef.current !== null) + window.clearTimeout(copiedTimerRef.current); }; }, []); - const showRawKey = React.useCallback(() => { - setMode("raw"); - void loadNsec(); - }, [loadNsec]); + const copyKeyToClipboard = React.useCallback(async () => { + setCopyState("copying"); + setCopyError(null); + try { + const value = nsec ?? (await getNsec()); + await writeTextToClipboard(value); + if (cancelledRef.current) return; + setCopyState("copied"); + if (copiedTimerRef.current !== null) + window.clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = window.setTimeout(() => { + if (!cancelledRef.current) setCopyState("idle"); + }, 2000); + } catch (err) { + if (cancelledRef.current) return; + setCopyState("idle"); + setCopyError( + err instanceof Error ? err.message : "Failed to retrieve private key.", + ); + } + }, [nsec]); + + const toggleReveal = React.useCallback(async () => { + if (isRevealed) { + setIsRevealed(false); + return; + } + setCopyError(null); + try { + // The raw key enters the DOM only after this explicit reveal action. + const value = nsec ?? (await getNsec()); + if (cancelledRef.current) return; + setNsec(value); + setIsRevealed(true); + } catch (err) { + if (cancelledRef.current) return; + setCopyError( + err instanceof Error ? err.message : "Failed to retrieve private key.", + ); + } + }, [isRevealed, nsec]); + + // Fixed-length decorative mask (nsec keys are 63 chars) so no key material + // is fetched just to render the blurred row. Bullets are joined with a + // zero-width space: WebKit won't line-break a run of U+2022 without an + // explicit break opportunity, so the masked row would overflow otherwise. + const maskedKey = React.useMemo( + () => Array.from({ length: nsec?.length ?? 63 }, () => "•").join("\u200b"), + [nsec], + ); return ( - {mode === "encrypted" ? ( - -
-
-

Save a Keycase

-

- A Keycase is still private. Never publish or share it. You - need both the file and password to restore your identity. -

+ {view === "choose" ? ( +
+ +
+
+

+ {isRevealed && nsec ? nsec : maskedKey} +

+
+
+ + + + + + + Copy your key and save it somewhere safe — a password + manager is a great place for it. + + +
- -
- - ) : isLoading ? ( -
- - Loading your private key… -
- ) : loadError ? ( -
-
- + {copyError ? ( +

+ Could not retrieve your private key: {copyError}. You can + continue and find it later in Settings > Profile > + Identity. +

+ ) : null} + + +

+ - Could not retrieve your private key: {loadError}. You can - continue and find it later in Settings > Profile > - Identity. + Never share your private key. Anyone with this key can + impersonate you and access everything in your account. -

- +

- ) : nsec ? ( - -
- -
-
) : ( -

- No key available to back up. -

- )} - - {mode === "encrypted" ? ( -
- +
+ +
+
+

Download your key

+

+ Your key is encrypted with your password before it + downloads. Keep the file private — you need both it and + the password to restore your identity. +

+
+ +
+
+ setView("choose")} />
- ) : null} - - {mode === "raw" && nsec ? ( -

- - - Never share your private key. Anyone with this key can - impersonate you and access everything in your account. - -

- ) : null} + )}
)} @@ -235,15 +336,15 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { Next - {mode === "raw" && loadError ? ( + {view === "choose" ? ( ) : null} diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx index 5433d9445..8399b70c8 100644 --- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -23,14 +23,15 @@ import { NsecMaskedDisplay } from "./NsecMaskedDisplay"; type EncryptedBackupCreatorProps = { /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ variant?: "spotlight" | "boxed"; - /** Fired only after the portable Keycase has been saved successfully. */ + /** Fired only after the encrypted key file has been saved successfully. */ onSaved?: (path: string) => void; }; /** - * Password-first Keycase creation flow shared by onboarding and Settings. - * The raw private key never enters this component. Rust creates the NIP-49 - * payload locally, then the native save dialog produces the user-owned file. + * Password-first encrypted key download flow shared by onboarding and + * Settings. The raw private key never enters this component. Rust creates the + * NIP-49 payload locally, then the native save dialog produces the user-owned + * file. */ export function EncryptedBackupCreator({ variant = "spotlight", @@ -57,7 +58,7 @@ export function EncryptedBackupCreator({ message: err instanceof Error ? err.message - : "Failed to generate a Keycase password.", + : "Failed to generate a password.", }); } }, []); @@ -89,7 +90,7 @@ export function EncryptedBackupCreator({ } catch (err) { if (mountedRef.current) setSaveError( - err instanceof Error ? err.message : "Failed to save Keycase.", + err instanceof Error ? err.message : "Failed to save your key.", ); } finally { if (mountedRef.current) setIsSaving(false); @@ -99,7 +100,7 @@ export function EncryptedBackupCreator({ dispatch({ type: "create-failed", message: - err instanceof Error ? err.message : "Failed to create Keycase.", + err instanceof Error ? err.message : "Failed to encrypt your key.", }); } }, [onSaved, state]); @@ -117,7 +118,7 @@ export function EncryptedBackupCreator({ } catch (err) { if (mountedRef.current) setSaveError( - err instanceof Error ? err.message : "Failed to save Keycase.", + err instanceof Error ? err.message : "Failed to save your key.", ); } finally { if (mountedRef.current) setIsSaving(false); @@ -149,14 +150,14 @@ export function EncryptedBackupCreator({ variant="outline" > {isSaving ? : null} - Save Keycase… + Save a copy… {savedPath ? (

- Keycase saved to {savedPath} + Saved to {savedPath}

) : null}
@@ -164,7 +165,7 @@ export function EncryptedBackupCreator({

{saveError}

) : null}

- Keep this Keycase private. You need both the file and its password to + Keep this file private. You need both the file and its password to restore your identity. Buzz cannot reset the password.

@@ -193,9 +194,7 @@ export function EncryptedBackupCreator({ data-testid="backup-passphrase-generate-error" > - - Could not generate a Keycase password: {state.generateError} - + Could not generate a password: {state.generateError} ) : (
@@ -227,15 +226,15 @@ export function EncryptedBackupCreator({

- Save this generated passphrase as your Keycase password. Store it - separately from the private Keycase file. + Save this generated passphrase as your encryption password. Store it + separately from the downloaded key file.

) : (

- Your password protects the Keycase. Buzz cannot reset it if lost. + Your password protects your downloaded key. Buzz cannot reset it if + lost.

)} @@ -311,10 +311,10 @@ export function EncryptedBackupCreator({ {state.isCreating ? ( <> - Creating Keycase… this takes a couple of seconds + Encrypting… this takes a couple of seconds ) : ( - "Create Keycase" + "Encrypt and download" )} diff --git a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx index 1dda9b5e2..78c9ec24a 100644 --- a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx +++ b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx @@ -9,8 +9,8 @@ type NsecMaskedDisplayProps = { variant?: "boxed" | "bare"; /** * What kind of secret is displayed. Drives labels, aria and testids: - * a raw private key ("nsec", default) can impersonate its holder; a - * Keycase ("ncryptsec") is only as sensitive as its passphrase. + * a raw private key ("nsec", default) can impersonate its holder; an + * encrypted key ("ncryptsec") is only as sensitive as its passphrase. */ kind?: "nsec" | "ncryptsec"; /** @@ -26,7 +26,7 @@ const KIND_LABELS = { testIdPrefix: "nsec", }, ncryptsec: { - noun: "Keycase", + noun: "encrypted key", testIdPrefix: "ncryptsec", }, } as const; diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts index 99fa40192..4570b002c 100644 --- a/desktop/tests/e2e/onboarding-backup.spec.ts +++ b/desktop/tests/e2e/onboarding-backup.spec.ts @@ -11,6 +11,14 @@ async function enterMachineBackup(page: import("@playwright/test").Page) { await page.getByRole("button", { name: "Create a new identity key" }).click(); } +async function invokedCommands(page: import("@playwright/test").Page) { + return page.evaluate( + () => + (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }) + .__BUZZ_E2E_COMMANDS__ ?? [], + ); +} + const SHOTS = "test-results/screenshots-onboarding"; test("backup step appears on fresh-key path after profile submit", async ({ @@ -36,24 +44,70 @@ test("backup step appears on fresh-key path after profile submit", async ({ }); // --------------------------------------------------------------------------- -// Keycase path: password → create locally → native save → saved confirmation. -// The raw key must never be fetched on this path. +// Chooser: masked key with reveal toggle and inline copy. The raw key is +// fetched only on explicit reveal/copy, and Next is never blocked. // --------------------------------------------------------------------------- -test("Keycase happy path: generated password, create, native save, Next", async ({ +test("chooser shows masked key; reveal and copy fetch it explicitly", async ({ + page, +}) => { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await enterMachineBackup(page); + + await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0); + + // Masked by default: decorative mask only, no key material in the DOM. + const key = page.getByTestId("backup-key-value"); + await expect(key).toBeVisible(); + await expect(key).toHaveClass(/blur/); + await expect(key).not.toContainText("nsec1"); + expect(await invokedCommands(page)).not.toContain("get_nsec"); + + // Reveal fetches the key; box must not reflow (same-length monospace mask). + await page.getByTestId("backup-key-reveal-toggle").click(); + await expect(key).toContainText("nsec1mock"); + await expect(key).toHaveClass(/select-text/); + + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/02-backup-chooser-revealed.png` }); + + // Hide again. + await page.getByTestId("backup-key-reveal-toggle").click(); + await expect(key).not.toContainText("nsec1"); + + // Inline copy goes straight to the clipboard. + await page.getByTestId("backup-copy-key").click(); + await expect + .poll(async () => invokedCommands(page)) + .toContain("copy_text_to_clipboard"); + expect(await invokedCommands(page)).toContain("get_nsec"); + + // Backup is recommended, never required. + await expect(page.getByTestId("onboarding-next")).toBeEnabled(); + await page.getByTestId("onboarding-next").click(); + await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); +}); + +// --------------------------------------------------------------------------- +// Encrypted download path: password → encrypt locally → native save → saved +// confirmation. The raw key must never be fetched on this path. +// --------------------------------------------------------------------------- + +test("download happy path: generated password, encrypt, native save, Next", async ({ page, }) => { await enterMachineBackup(page); + // The download flow sits behind the footer CTA. + await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0); + await page.getByTestId("backup-option-download").click(); + // Default mode: generated password shown, but backup remains optional. await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible(); await expect(page.getByTestId("onboarding-next")).toBeEnabled(); - // Let the perceived-loading intro (animated logo → content fade-in) finish - // so the screenshot captures fully opaque content. - await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0); await waitForAnimations(page); - await page.screenshot({ path: `${SHOTS}/02-backup-step-passphrase.png` }); + await page.screenshot({ path: `${SHOTS}/03-backup-download-passphrase.png` }); await page.getByTestId("encrypted-backup-create").click(); @@ -65,18 +119,14 @@ test("Keycase happy path: generated password, create, native save, Next", async await expect(blob).toContainText("ncryptsec1"); await waitForAnimations(page); - await page.screenshot({ path: `${SHOTS}/03-backup-step-encrypted.png` }); + await page.screenshot({ path: `${SHOTS}/04-backup-download-encrypted.png` }); await expect(page.getByTestId("encrypted-backup-saved-path")).toContainText( "identity.ncryptsec", ); - // The default path must never have fetched the raw key. - const commands = await page.evaluate( - () => - (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }) - .__BUZZ_E2E_COMMANDS__ ?? [], - ); + // The download path must never have fetched the raw key. + const commands = await invokedCommands(page); expect(commands).not.toContain("get_nsec"); expect(commands).toContain("create_ncryptsec_backup"); @@ -85,10 +135,26 @@ test("Keycase happy path: generated password, create, native save, Next", async await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); }); +test("download view returns to the chooser via All backup options", async ({ + page, +}) => { + await enterMachineBackup(page); + + await page.getByTestId("backup-option-download").click(); + await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible(); + // The footer download CTA hides while the flow is open. + await expect(page.getByTestId("backup-option-download")).toHaveCount(0); + + await page.getByTestId("backup-back-to-options").click(); + await expect(page.getByTestId("backup-key-value")).toBeVisible(); + await expect(page.getByTestId("backup-option-download")).toBeVisible(); +}); + test("custom passphrase requires 12 characters and confirmation", async ({ page, }) => { await enterMachineBackup(page); + await page.getByTestId("backup-option-download").click(); await page.getByTestId("backup-passphrase-choose-own").click(); const create = page.getByTestId("encrypted-backup-create"); @@ -108,41 +174,6 @@ test("custom passphrase requires 12 characters and confirmation", async ({ await expect(create).toBeEnabled(); }); -// --------------------------------------------------------------------------- -// Raw-key path: preserved behind one explicit advanced action. -// --------------------------------------------------------------------------- - -test("raw key path is one explicit click away and shows the masked nsec", async ({ - page, -}) => { - await enterMachineBackup(page); - - await page.getByTestId("backup-show-raw-key").click(); - - const nsecDisplay = page.getByTestId("nsec-value"); - await expect(nsecDisplay).toBeVisible(); - - // Should start masked (blurred) — reveal button exists and eye icon visible. - const revealBtn = page.getByTestId("nsec-reveal-toggle"); - await expect(revealBtn).toBeVisible(); - await expect(nsecDisplay).toHaveCSS("filter", /blur/); - - // Reveal and verify the mock nsec appears. - await revealBtn.click(); - await expect(nsecDisplay).not.toHaveCSS("filter", /blur/); - await expect(nsecDisplay).toContainText("nsec1mock"); - - // Intro crossfade must be finished before capturing. - await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0); - await waitForAnimations(page); - await page.screenshot({ path: `${SHOTS}/04-backup-step-raw-revealed.png` }); - - // Next remains enabled on the advanced raw-key path. - await expect(page.getByTestId("onboarding-next")).toBeEnabled(); - await page.getByTestId("onboarding-next").click(); - await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); -}); - test("backup step back button returns to machine identity choice", async ({ page, }) => { @@ -162,10 +193,10 @@ test("backup step back button returns to machine identity choice", async ({ }); // --------------------------------------------------------------------------- -// B4: Error path coverage (raw path) +// B4: Error path coverage (reveal/copy) // --------------------------------------------------------------------------- -test("raw path shows error banner and retry button when get_nsec fails", async ({ +test("reveal shows inline error when get_nsec fails and Next still advances", async ({ page, }) => { await installMockBridge( @@ -177,22 +208,16 @@ test("raw path shows error banner and retry button when get_nsec fails", async ( await page.getByRole("button", { name: "Create a new identity key" }).click(); await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); - await page.getByTestId("backup-show-raw-key").click(); + await page.getByTestId("backup-key-reveal-toggle").click(); - await expect(page.getByTestId("backup-load-error")).toBeVisible(); - await expect(page.getByTestId("backup-retry")).toBeVisible(); - // Keychain failure does not trap the user; both Next and explicit skip work. + await expect(page.getByTestId("backup-copy-error")).toBeVisible(); + // Keychain failure does not trap the user. await expect(page.getByTestId("onboarding-next")).toBeEnabled(); - await expect(page.getByTestId("backup-skip")).toBeVisible(); - - // Skip for now still advances to machine setup. - await page.getByTestId("backup-skip").click(); + await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); }); -test("raw path retry succeeds and shows key after initial failure", async ({ - page, -}) => { +test("reveal retry succeeds after initial failure", async ({ page }) => { // First call fails, second succeeds (sequenced via nsecErrors). await installMockBridge( page, @@ -201,12 +226,12 @@ test("raw path retry succeeds and shows key after initial failure", async ({ ); await page.goto("/"); await page.getByRole("button", { name: "Create a new identity key" }).click(); - await page.getByTestId("backup-show-raw-key").click(); - await expect(page.getByTestId("backup-load-error")).toBeVisible(); + await page.getByTestId("backup-key-reveal-toggle").click(); + await expect(page.getByTestId("backup-copy-error")).toBeVisible(); - // Retry — second call succeeds. - await page.getByTestId("backup-retry").click(); - await expect(page.getByTestId("nsec-value")).toBeVisible(); - await expect(page.getByTestId("backup-load-error")).not.toBeVisible(); + // Retry — second call succeeds and clears the error. + await page.getByTestId("backup-key-reveal-toggle").click(); + await expect(page.getByTestId("backup-key-value")).toContainText("nsec1mock"); + await expect(page.getByTestId("backup-copy-error")).not.toBeVisible(); }); diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts index ee6bd3fee..f9d7c72af 100644 --- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts @@ -59,13 +59,12 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({ await waitForAnimations(page); await page.screenshot({ path: `${SHOT_DIR}/02-backup.png` }); - // Encrypted-by-default backup: the raw key sits behind an explicit click. - await page.getByTestId("backup-show-raw-key").click(); - await expect(page.getByTestId("nsec-value")).toBeVisible(); + // The key stays masked behind an explicit reveal toggle. + await expect(page.getByTestId("backup-key-value")).toBeVisible(); // Reveal the key: box must not reflow (same-length monospace mask). - await page.getByTestId("nsec-reveal-toggle").click(); - await expect(page.getByTestId("nsec-value")).toHaveClass(/select-text/); + await page.getByTestId("backup-key-reveal-toggle").click(); + await expect(page.getByTestId("backup-key-value")).toHaveClass(/select-text/); await waitForAnimations(page); await page.screenshot({ path: `${SHOT_DIR}/02b-backup-revealed.png` });