diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index 4419b6e63..2ab743491 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -34,7 +34,7 @@ let introPlayed = false; const REVEAL_ANIMATION_CLASS = "animate-in fade-in duration-700 motion-reduce:animate-none"; -/** Saving a Keycase is recommended, never required to continue onboarding. */ +/** Viewing the key never blocks onboarding — Next is always actionable. */ export function backupNextDisabled(): boolean { return false; } @@ -44,22 +44,16 @@ type BackupStepProps = { onBack: () => void; /** Advances to the dedicated "Download your key" onboarding step. */ onDownload: () => void; - onNext: () => void; }; /** - * Onboarding backup step — shows the freshly created key and offers two ways - * to back it up without blocking setup: the encrypted download flow (its own - * onboarding step, via `onDownload`) or a direct clipboard copy destined for - * a password manager. The raw key is fetched only when the user explicitly + * Onboarding backup step — shows the freshly created key and offers a direct + * clipboard copy destined for a password manager. Next leads into the + * encrypted download step (its own onboarding page, via `onDownload`), which + * is skippable there. The raw key is fetched only when the user explicitly * clicks Copy or Reveal, and is never held in state before that. */ -export function BackupStep({ - direction, - onBack, - onDownload, - onNext, -}: BackupStepProps) { +export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) { const [created, setCreated] = React.useState(introPlayed); const [copyState, setCopyState] = React.useState< "idle" | "copying" | "copied" @@ -284,28 +278,15 @@ export function BackupStep({ {created ? ( - {/* Relative row keeps the primary CTA truly centered while Skip - hangs off its right edge without shifting the center. */} -
- - -
+ + {fileError ? ( +

+ {fileError} +

+ ) : null} +
+ + {savedPath ? ( +

+ Saved to {savedPath} +

+ ) : null} +
+ {saveError ? ( +

{saveError}

+ ) : null} + + ) : ( + <> +
+
+

+ That's the one. Now type your password to prove you can unlock it. +

+
+ handlePasswordChange(event.target.value)} + placeholder="Your backup password" + ref={passwordInputRef} + type={isRevealed ? "text" : "password"} + value={attempt} + /> + + {attemptWrong ? ( +

+ Not quite — check the password you saved. +

+ ) : null} +
+
+ +
+ + )} + + ); +} diff --git a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx index ec6ac72a4..25e18f348 100644 --- a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx +++ b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx @@ -1,7 +1,6 @@ import { motion, useReducedMotion } from "motion/react"; import * as React from "react"; -import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; @@ -12,49 +11,6 @@ import { } from "./OnboardingSlideTransition"; import { EncryptedBackupCreator } from "./EncryptedBackupCreator"; -/** - * One half of the "[ ]" pair that clamps around the shell box once it - * settles — the visual is the user working on the secure shell that - * surrounds their key. Brackets close inward as they fade in. - */ -function ShellBracket({ - reduceMotion, - side, - visible, -}: { - reduceMotion: boolean; - side: "left" | "right"; - visible: boolean; -}) { - return ( - - ); -} - type DownloadKeyStepProps = { direction: OnboardingTransitionDirection; onBack: () => void; @@ -73,8 +29,6 @@ export function DownloadKeyStep({ onNext, }: DownloadKeyStepProps) { const reduceMotion = useReducedMotion() ?? false; - // True once the shell box has landed; gates the closing brackets. - const [shellSettled, setShellSettled] = React.useState(false); // True once the encrypted payload exists — the create button (living in the // footer's primary slot) disappears with the form, so Next takes its place. const [hasCreated, setHasCreated] = React.useState(false); @@ -93,41 +47,17 @@ export function DownloadKeyStep({ {/* Plain string concat: cn()'s tailwind-merge misreads the custom text-title size token as conflicting with text-foreground. */}

- Backup your key + {hasCreated ? "Test your backup" : "Backup your key with a password"}

- Keep the downloaded file private — you need both it and your password - to restore your identity. Save the password somewhere safe; Buzz - cannot reset it if lost. + {hasCreated + ? "Make sure your backup works: drop the file you just saved and unlock it with your password." + : "Keep the downloaded file private — you need both it and your password to restore your identity. Save the backup password somewhere safe; Buzz cannot reset it if lost."}

-
- - setShellSettled(true)} - transition={ - reduceMotion - ? { duration: 0 } - : { duration: 0.45, ease: [0.22, 1, 0.36, 1] } - } - /> - -
) : ( -
+ /* Relative row keeps the Download CTA truly centered while Skip + hangs off its right edge without shifting the center. */ +
+
+ +
)} - -

- You can back up your key anytime in Settings → Profile → - Identity. -

); diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx index 13bc42908..bcca3fb09 100644 --- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -21,7 +21,7 @@ import { initialEncryptedBackupState, MIN_PASSPHRASE_LEN, } from "../lib/encryptedBackup"; -import { NsecMaskedDisplay } from "./NsecMaskedDisplay"; +import { BackupTestFlow } from "./BackupTestFlow"; /** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */ const MIN_GENERATED_WORDS = 3; @@ -440,46 +440,20 @@ export function EncryptedBackupCreator({ } }, [isSaving, onSaved, state.ncryptsec]); - const isSpotlight = variant === "spotlight"; const issue = passphraseIssue(state.passphrase); if (state.ncryptsec) { return ( -
- + void handleSaveCopy()} + passphrase={state.passphrase} + saveError={saveError} + savedPath={savedPath} + variant={variant} /> -
- - {savedPath ? ( -

- Saved to {savedPath} -

- ) : null} -
- {saveError ? ( -

{saveError}

- ) : null} -

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

); } @@ -565,7 +539,7 @@ export function EncryptedBackupCreator({ onClick={() => dispatch({ type: "download-clicked" })} type="button" > - {state.downloadPending ? : "Download"} + {state.downloadPending ? : "Backup key"}
); diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index 0cdec027e..bdeaaa530 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -238,7 +238,6 @@ export function MachineOnboardingFlow({ direction="forward" onBack={() => setPage("identity")} onDownload={() => setPage("download")} - onNext={() => setPage("setup")} /> ) : page === "download" ? ( { @@ -82,9 +88,11 @@ test("chooser shows masked key; reveal and copy fetch it explicitly", async ({ .toContain("copy_text_to_clipboard"); expect(await invokedCommands(page)).toContain("get_nsec"); - // Backup is recommended, never required. + // Next leads into the download step, where backup stays skippable. await expect(page.getByTestId("onboarding-next")).toBeEnabled(); await page.getByTestId("onboarding-next").click(); + await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); + await page.getByTestId("onboarding-skip").click(); await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); }); @@ -99,9 +107,9 @@ test("download happy path: generated password, encrypt, native save, Next", asyn }) => { await enterMachineBackup(page); - // The download flow is its own onboarding step behind the footer CTA. + // The download flow is its own onboarding step behind the footer's Next. await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0); - await page.getByTestId("backup-option-download").click(); + await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); // The password field starts empty; the create button sits in the footer's @@ -110,6 +118,7 @@ test("download happy path: generated password, encrypt, native save, Next", asyn await expect(input).toHaveValue(""); await expect(page.getByTestId("encrypted-backup-create")).toBeDisabled(); await expect(page.getByTestId("onboarding-next")).toHaveCount(0); + await expect(page.getByTestId("onboarding-skip")).toBeVisible(); // The inset refresh icon opens the generator popover and immediately // fills the field (mock default: 3 words, spaces). @@ -139,20 +148,56 @@ test("download happy path: generated password, encrypt, native save, Next", asyn await page.getByTestId("encrypted-backup-create").click(); - // The locally created blob stays masked; the portable save action is explicit. - const blob = page.getByTestId("ncryptsec-value"); - await expect(blob).toBeVisible(); - await expect(blob).toHaveCSS("filter", /blur/); - await page.getByTestId("ncryptsec-reveal-toggle").click(); - await expect(blob).toContainText("ncryptsec1"); - - await waitForAnimations(page); - await page.screenshot({ path: `${SHOTS}/04-backup-download-encrypted.png` }); - + // Download commits the blob and hands over to the "Test your backup" + // flow: a dropzone for the saved file, then the password to unlock it. + await expect( + page.getByRole("heading", { name: "Test your backup" }), + ).toBeVisible(); + const dropzone = page.getByTestId("backup-test-dropzone"); + await expect(dropzone).toBeVisible(); await expect(page.getByTestId("encrypted-backup-saved-path")).toContainText( "identity.ncryptsec", ); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/04-backup-test-dropzone.png` }); + + // A wrong file is rejected with an inline error; the dropzone stays. + await page.getByTestId("backup-test-file-input").setInputFiles({ + name: "notes.txt", + mimeType: "text/plain", + buffer: Buffer.from("not a keycase"), + }); + await expect(page.getByTestId("backup-test-file-error")).toBeVisible(); + + // The freshly downloaded file advances to the password check. + await page.getByTestId("backup-test-file-input").setInputFiles({ + name: "identity.ncryptsec", + mimeType: "text/plain", + buffer: Buffer.from(MOCK_NCRYPTSEC), + }); + const password = page.getByTestId("backup-test-password"); + await expect(password).toBeVisible(); + + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/05-backup-test-password.png` }); + + // A full-length wrong password shows the mismatch hint, never success. + await password.fill("mock-horse-battery-staplX"); + await expect(page.getByTestId("backup-test-password-mismatch")).toBeVisible(); + await expect(page.getByTestId("backup-test-success")).toHaveCount(0); + + // Typing the password completely succeeds without any extra click. + await password.fill("mock-horse-battery-staple"); + await expect(page.getByTestId("backup-test-success")).toBeVisible(); + + // The celebration is driven by motion's rAF loop, which + // `waitForAnimations` (WAAPI-only) cannot observe — hold until the badge + // and copy have faded in before capturing. + await page.waitForTimeout(1200); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/06-backup-test-success.png` }); + // The download path must never have fetched the raw key. const commands = await invokedCommands(page); expect(commands).not.toContain("get_nsec"); @@ -166,21 +211,22 @@ test("download happy path: generated password, encrypt, native save, Next", asyn test("download step Back returns to the backup chooser", async ({ page }) => { await enterMachineBackup(page); - await page.getByTestId("backup-option-download").click(); + await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); await expect(page.getByTestId("backup-passphrase-input")).toBeVisible(); - // The chooser's footer CTA belongs to the previous step. - await expect(page.getByTestId("backup-option-download")).toHaveCount(0); + // The chooser's footer Next belongs to the previous step; the download + // step only mounts its own Next once the backup exists. + await expect(page.getByTestId("onboarding-next")).toHaveCount(0); await page.getByTestId("onboarding-back").click(); await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); await expect(page.getByTestId("backup-key-value")).toBeVisible(); - await expect(page.getByTestId("backup-option-download")).toBeVisible(); + await expect(page.getByTestId("onboarding-next")).toBeVisible(); }); test("typed password requires 12 characters", async ({ page }) => { await enterMachineBackup(page); - await page.getByTestId("backup-option-download").click(); + await page.getByTestId("onboarding-next").click(); const create = page.getByTestId("encrypted-backup-create"); await expect(create).toBeDisabled(); // empty field @@ -233,9 +279,12 @@ test("reveal shows inline error when get_nsec fails and Next still advances", as await page.getByTestId("backup-key-reveal-toggle").click(); await expect(page.getByTestId("backup-copy-error")).toBeVisible(); - // Keychain failure does not trap the user. + // Keychain failure does not trap the user: Next still advances into the + // download step, and Skip there continues to setup. await expect(page.getByTestId("onboarding-next")).toBeEnabled(); await page.getByTestId("onboarding-next").click(); + await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); + await page.getByTestId("onboarding-skip").click(); await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); }); diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts index f9d7c72af..66965c7ef 100644 --- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts @@ -68,7 +68,10 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({ await waitForAnimations(page); await page.screenshot({ path: `${SHOT_DIR}/02b-backup-revealed.png` }); + // Next leads into the download step; Skip there continues to setup. await page.getByTestId("onboarding-next").click(); + await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); + await page.getByTestId("onboarding-skip").click(); await expect( page.getByRole("heading", { name: "Set up your agent harnesses" }), ).toBeVisible(); diff --git a/desktop/tests/helpers/onboarding.ts b/desktop/tests/helpers/onboarding.ts index be36a852a..afcb3e64f 100644 --- a/desktop/tests/helpers/onboarding.ts +++ b/desktop/tests/helpers/onboarding.ts @@ -16,10 +16,12 @@ export async function seedActiveIdentity( ); } -/** Navigate through the backup step (fresh-key path). */ +/** Navigate through the backup steps (fresh-key path). */ export async function passThroughBackupStep(page: Page) { await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); - // Backing up is recommended, never required — "Skip for now" advances - // straight to setup without visiting the "Backup your key" step. + // Next always leads into the "Backup your key" step; backing up is + // recommended, never required — "Skip for now" there advances to setup. await page.getByTestId("onboarding-next").click(); + await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); + await page.getByTestId("onboarding-skip").click(); }