From 418c5f511c55275d4adb8cf737598b37159c0218 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 29 Jul 2026 00:36:50 -0700 Subject: [PATCH] feat(onboarding): durable backup session with save-gated test flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Back navigation in the machine onboarding flow no longer destroys in-progress key-backup work, and the "Now, test your backup" view only appears once the file is actually on disk. - EncryptedBackupCreator.tsx: new useEncryptedBackupSession() hook lifts everything that must survive step unmounts — the encrypt reducer state (entered password, cached KDF result, committed blob), the verified flag, the saved path, the save-once guard, and the test-flow progress — into a host-owned session. The creator accepts an optional session prop and falls back to a private one, so the settings "Save a new Keycase" card is unchanged. KDF completions now dispatch unguarded so results landing while the step is unmounted still commit. - EncryptedBackupCreator.tsx: advancing to the test view now requires a successful save, not just a committed blob. Canceling the native save dialog (or a save failure) rolls the commit back to the password form and clears the save-once guard so "Download backup" can be clicked again; save errors now render on the form. CTA renamed "Backup key" → "Download backup". - encryptedBackup.ts: new "back-to-password" reducer event discards the committed blob while keeping the passphrase and its cached encryption result, so returning to the form re-downloads instantly without a second KDF run. Covered by a new unit test. - BackupTestFlow.tsx: drop/password/success progress is host-controlled (survives remounts), and the wrong-password hint no longer compares attempt length against the real password (which leaked its length) — it shows only after a 900ms typing pause or an explicit Enter. - DownloadKeyStep.tsx: Back from the test view returns to the password form (via backupSessionToPasswordEntry) instead of leaving the step; the pre-verification footer shows a ghost "Skip for now" instead of a disabled Next; the "back up anytime in Settings" hint only renders on the password form; hasCreated/hasVerified derive from the session (session.created keeps the step outside the ncryptsec confinement scan). - MachineOnboardingFlow.tsx: owns the backup session; the setup (providers) step's Back returns fresh-key users to the "Backup your key with a password" form rather than the key-created page. - BackupStep.tsx: footer hint moved to the download step. - onboarding-backup.spec.ts: pre-verification footer asserts Next is absent (not disabled), documents save-gating, and adds a spec covering test-view Back → password form with the password intact plus instant re-download from the cached encryption. Signed-off-by: Taylor Ho Co-authored-by: Taylor Ho --- .../onboarding/lib/encryptedBackup.test.mjs | 25 +++ .../onboarding/lib/encryptedBackup.ts | 15 +- .../src/features/onboarding/ui/BackupStep.tsx | 5 - .../features/onboarding/ui/BackupTestFlow.tsx | 83 ++++++-- .../onboarding/ui/DownloadKeyStep.tsx | 63 ++++-- .../onboarding/ui/EncryptedBackupCreator.tsx | 196 ++++++++++++++---- .../onboarding/ui/MachineOnboardingFlow.tsx | 21 +- desktop/tests/e2e/onboarding-backup.spec.ts | 47 ++++- 8 files changed, 365 insertions(+), 90 deletions(-) diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs index d300ab852..8db64b621 100644 --- a/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs +++ b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs @@ -211,6 +211,31 @@ test("queued_download_clears_on_failure_so_user_can_retry", () => { assert.equal(downloadDisabled(failed), false, "retry allowed after failure"); }); +test("back_to_password_discards_commit_but_keeps_cached_encryption", () => { + const back = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", passphrase: "one-two-three-four" }, + { + type: "encrypt-succeeded", + passphrase: "one-two-three-four", + ncryptsec: "ncryptsec1abc", + }, + { type: "download-clicked" }, + { type: "back-to-password" }, + ]); + assert.equal(back.ncryptsec, null, "committed blob discarded"); + assert.equal(back.passphrase, "one-two-three-four", "passphrase kept"); + assert.equal( + pendingEncryptPassphrase(back), + null, + "cached result means no re-encryption", + ); + + // Re-downloading the unchanged passphrase commits instantly from cache. + const recommitted = reduce([{ type: "download-clicked" }], back); + assert.equal(recommitted.ncryptsec, "ncryptsec1abc"); +}); + test("download_click_before_encryption_starts_still_queues", () => { // Click lands inside the debounce window: nothing started yet, but the // pending flag makes the eventual result commit. diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.ts b/desktop/src/features/onboarding/lib/encryptedBackup.ts index ff616bf96..4f74c72e2 100644 --- a/desktop/src/features/onboarding/lib/encryptedBackup.ts +++ b/desktop/src/features/onboarding/lib/encryptedBackup.ts @@ -52,7 +52,13 @@ export type EncryptedBackupEvent = | { type: "encrypt-started"; passphrase: string } | { type: "encrypt-succeeded"; passphrase: string; ncryptsec: string } | { type: "encrypt-failed"; passphrase: string; message: string } - | { type: "download-clicked" }; + | { type: "download-clicked" } + /** + * Return from the post-download test view to the password form (onboarding + * Back). The committed blob is discarded but the passphrase and its cached + * encryption result are kept, so re-downloading is instant. + */ + | { type: "back-to-password" }; export function encryptedBackupReducer( state: EncryptedBackupState, @@ -111,6 +117,13 @@ export function encryptedBackupReducer( } return { ...state, downloadPending: true }; } + case "back-to-password": + return { + ...state, + ncryptsec: null, + downloadPending: false, + createError: null, + }; } } diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index 2ab743491..1d1b1d0bc 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -297,11 +297,6 @@ export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) { > Back - -

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

) : null} diff --git a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx index 3645312f3..5145da091 100644 --- a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx +++ b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx @@ -9,6 +9,25 @@ import { Spinner } from "@/shared/ui/spinner"; type BackupTestStage = "drop" | "password" | "success"; +/** + * Durable progress through the test flow. Owned by the host so navigating + * away (e.g. onboarding Back) and returning doesn't force the user to + * re-drop the file or retype their attempt. + */ +export type BackupTestProgress = { + stage: BackupTestStage; + /** Name of the accepted file once the drop check passed. */ + fileName: string | null; + /** The password attempt typed so far. */ + attempt: string; +}; + +export const initialBackupTestProgress: BackupTestProgress = { + stage: "drop", + fileName: null, + attempt: "", +}; + type BackupTestFlowProps = { /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ variant?: "spotlight" | "boxed"; @@ -21,10 +40,19 @@ type BackupTestFlowProps = { isSaving: boolean; savedPath: string | null; saveError: string | null; + /** Host-owned progress so it survives this component unmounting. */ + progress: BackupTestProgress; + onProgressChange: React.Dispatch>; /** Fired once when the user completes the test successfully. */ onVerified?: () => void; }; +/** + * How long after the last keystroke before a wrong attempt is called out. + * Verification itself is instant on match; this only delays the scolding. + */ +const MISMATCH_HINT_PAUSE_MS = 900; + const BURST_EMOJIS = ["🎉", "✨", "🐝", "🍯", "🔑", "💛"] as const; const BURST_PARTICLE_COUNT = 18; @@ -108,10 +136,12 @@ export function BackupTestFlow({ isSaving, savedPath, saveError, + progress, + onProgressChange, onVerified, }: BackupTestFlowProps) { const reduceMotion = useReducedMotion() ?? false; - const [stage, setStage] = React.useState("drop"); + const { stage, fileName, attempt } = progress; const [isDragActive, setIsDragActive] = React.useState(false); // True while a file drag is anywhere over the window — the select button // renders as a dropzone only for the duration of the drag. @@ -145,9 +175,7 @@ export function BackupTestFlow({ window.removeEventListener("dragend", handleDragEnd); }; }, []); - const [fileName, setFileName] = React.useState(null); const [fileError, setFileError] = React.useState(null); - const [attempt, setAttempt] = React.useState(""); const [isRevealed, setIsRevealed] = React.useState(false); const fileInputRef = React.useRef(null); const passwordInputRef = React.useRef(null); @@ -166,12 +194,12 @@ export function BackupTestFlow({ }, [stage]); const succeed = React.useCallback(() => { - setStage("success"); + onProgressChange((prev) => ({ ...prev, stage: "success" })); if (!verifiedFiredRef.current) { verifiedFiredRef.current = true; onVerified?.(); } - }, [onVerified]); + }, [onProgressChange, onVerified]); const handleFile = React.useCallback( async (file: File) => { @@ -185,9 +213,12 @@ export function BackupTestFlow({ if (!mountedRef.current) return; const trimmed = text.trim(); if (trimmed === ncryptsec.trim()) { - setFileName(file.name); setFileError(null); - setStage("password"); + onProgressChange((prev) => ({ + ...prev, + stage: "password", + fileName: file.name, + })); } else if (trimmed.toLowerCase().startsWith("ncryptsec1")) { setFileError( "That's a Keycase file, but not the one you just downloaded.", @@ -198,24 +229,34 @@ export function BackupTestFlow({ ); } }, - [ncryptsec], + [ncryptsec, onProgressChange], ); const handlePasswordChange = React.useCallback( (value: string) => { - setAttempt(value); + onProgressChange((prev) => ({ ...prev, attempt: value })); if (value === passphrase) succeed(); }, - [passphrase, succeed], + [onProgressChange, passphrase, succeed], ); + // Only scold after the user pauses (or presses Enter), never mid-typing — + // and never based on the attempt's length, which would leak how long the + // real password is. + const [mismatchVisible, setMismatchVisible] = React.useState(false); + React.useEffect(() => { + setMismatchVisible(false); + if (stage !== "password" || attempt.length === 0 || attempt === passphrase) + return; + const timer = window.setTimeout( + () => setMismatchVisible(true), + MISMATCH_HINT_PAUSE_MS, + ); + return () => window.clearTimeout(timer); + }, [stage, attempt, passphrase]); + const isSpotlight = variant === "spotlight"; - // Only scold once the attempt is at least as long as the real password — - // never mid-typing. - const attemptWrong = - stage === "password" && - attempt !== passphrase && - [...attempt].length >= [...passphrase].length; + const attemptWrong = stage === "password" && mismatchVisible; if (stage === "success") { return ( @@ -384,6 +425,12 @@ export function BackupTestFlow({ className="h-10 bg-background pr-10 font-mono" data-testid="backup-test-password" onChange={(event) => handlePasswordChange(event.target.value)} + onKeyDown={(event) => { + // Enter is an explicit "check it" — no need to wait out the + // typing pause before calling out a mismatch. + if (event.key === "Enter" && attempt.length > 0) + setMismatchVisible(attempt !== passphrase); + }} placeholder="Your backup password" ref={passwordInputRef} type={isRevealed ? "text" : "password"} @@ -418,9 +465,7 @@ export function BackupTestFlow({ className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground" data-testid="backup-test-use-different-file" onClick={() => { - setStage("drop"); - setFileName(null); - setAttempt(""); + onProgressChange(initialBackupTestProgress); setIsRevealed(false); }} size="sm" diff --git a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx index 2749bebe8..ce9c06ac6 100644 --- a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx +++ b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx @@ -9,10 +9,20 @@ import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "./OnboardingSlideTransition"; -import { EncryptedBackupCreator } from "./EncryptedBackupCreator"; +import { + type EncryptedBackupSession, + backupSessionToPasswordEntry, + EncryptedBackupCreator, +} from "./EncryptedBackupCreator"; type DownloadKeyStepProps = { direction: OnboardingTransitionDirection; + /** + * Backup state owned by the parent flow so Back navigation (which unmounts + * this step) doesn't discard the created Keycase, the entered password, or + * the backup-test progress. + */ + session: EncryptedBackupSession; onBack: () => void; onNext: () => void; }; @@ -25,16 +35,17 @@ type DownloadKeyStepProps = { */ export function DownloadKeyStep({ direction, + session, onBack, onNext, }: DownloadKeyStepProps) { const reduceMotion = useReducedMotion() ?? 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); + const hasCreated = session.created; // True once the user has passed the backup test — until then Next stays // disabled and "Skip for now" remains the escape hatch. - const [hasVerified, setHasVerified] = React.useState(false); + const hasVerified = session.verified; // Footer slot the creator portals its "Download" button into. const [createButtonSlot, setCreateButtonSlot] = React.useState(null); @@ -73,8 +84,7 @@ export function DownloadKeyStep({ setHasCreated(true)} - onVerified={() => setHasVerified(true)} + session={session} variant="spotlight" /> @@ -85,30 +95,28 @@ export function DownloadKeyStep({ {hasCreated ? ( - /* Relative row keeps Next truly centered while Skip hangs off its - right edge without shifting the center. */ -
+ hasVerified ? ( - {hasVerified ? null : ( - - )} -
+ ) : ( + /* No disabled Next while the test is unfinished — skipping is + the only way forward until verification succeeds. */ + + ) ) : ( /* Relative row keeps the Download CTA truly centered while Skip hangs off its right edge without shifting the center. */ @@ -133,12 +141,23 @@ export function DownloadKeyStep({ + + {hasCreated ? null : ( +

+ 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 a892af326..c5aa4ac4e 100644 --- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -20,8 +20,14 @@ import { encryptedBackupReducer, initialEncryptedBackupState, MIN_PASSPHRASE_LEN, + type EncryptedBackupEvent, + type EncryptedBackupState, } from "../lib/encryptedBackup"; -import { BackupTestFlow } from "./BackupTestFlow"; +import { + type BackupTestProgress, + BackupTestFlow, + initialBackupTestProgress, +} from "./BackupTestFlow"; /** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */ const MIN_GENERATED_WORDS = 3; @@ -130,6 +136,81 @@ function PendingDownloadTicker() { ); } +/** + * Everything about an in-progress backup that must survive this component + * unmounting: the reducer state (passphrase + committed blob), whether the + * backup test passed, where the file was saved, the save-once guard, and the + * test-flow progress. Hosts that need the state to outlive the creator (the + * onboarding flow, where Back unmounts the step) call + * `useEncryptedBackupSession` at a longer-lived level and pass it down; + * otherwise the creator owns a private session internally. + */ +export type EncryptedBackupSession = { + state: EncryptedBackupState; + dispatch: React.Dispatch; + /** + * True once the encrypted payload has been committed AND saved to disk. + * Derived so hosts (e.g. DownloadKeyStep) can branch on it without touching + * the blob itself — keeping them outside the ncryptsec confinement scan. + */ + created: boolean; + /** True once the user has passed the backup test. */ + verified: boolean; + setVerified: React.Dispatch>; + savedPath: string | null; + setSavedPath: React.Dispatch>; + /** The committed blob a save was already kicked off for (save-once guard). */ + savedForRef: React.MutableRefObject; + test: BackupTestProgress; + setTest: React.Dispatch>; +}; + +/** Host-side state for `EncryptedBackupCreator` — see `EncryptedBackupSession`. */ +export function useEncryptedBackupSession(): EncryptedBackupSession { + const [state, dispatch] = React.useReducer( + encryptedBackupReducer, + initialEncryptedBackupState, + ); + const [verified, setVerified] = React.useState(false); + const [savedPath, setSavedPath] = React.useState(null); + const savedForRef = React.useRef(null); + const [test, setTest] = React.useState( + initialBackupTestProgress, + ); + return React.useMemo( + () => ({ + state, + dispatch, + created: state.ncryptsec !== null && savedPath !== null, + verified, + setVerified, + savedPath, + setSavedPath, + savedForRef, + test, + setTest, + }), + [state, verified, savedPath, test], + ); +} + +/** + * Roll a session back from the post-download test view to the password form + * (onboarding Back on "Now, test your backup"). The passphrase and its cached + * encryption result survive, so re-downloading is instant; the committed + * blob, test progress, verification, and save bookkeeping are discarded so a + * re-download runs the full save + test ceremony again. + */ +export function backupSessionToPasswordEntry( + session: EncryptedBackupSession, +): void { + session.dispatch({ type: "back-to-password" }); + session.setVerified(false); + session.setSavedPath(null); + session.savedForRef.current = null; + session.setTest(initialBackupTestProgress); +} + type EncryptedBackupCreatorProps = { /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ variant?: "spotlight" | "boxed"; @@ -140,6 +221,11 @@ type EncryptedBackupCreatorProps = { createButtonPortal?: HTMLElement | null; /** Extra classes for the "Download" button. */ createButtonClassName?: string; + /** + * Host-owned session so the backup state survives this component + * unmounting (onboarding Back navigation). Omitted = private session. + */ + session?: EncryptedBackupSession; /** Fired once the encrypted payload has been created (before saving). */ onCreated?: () => void; /** Fired only after the encrypted key file has been saved successfully. */ @@ -324,22 +410,19 @@ export function EncryptedBackupCreator({ variant = "spotlight", createButtonPortal, createButtonClassName, + session: sessionProp, onCreated, onSaved, onVerified, }: EncryptedBackupCreatorProps) { - const [state, dispatch] = React.useReducer( - encryptedBackupReducer, - initialEncryptedBackupState, - ); + // Hosts without a longer-lived session get a private one (settings card). + const fallbackSession = useEncryptedBackupSession(); + const session = sessionProp ?? fallbackSession; + const { state, dispatch, savedPath, setSavedPath, savedForRef } = session; const [isRevealed, setIsRevealed] = React.useState(false); - const [savedPath, setSavedPath] = React.useState(null); const [saveError, setSaveError] = React.useState(null); const [isSaving, setIsSaving] = React.useState(false); const mountedRef = React.useRef(true); - // The committed blob we've already kicked a save off for — guards the - // commit effect against re-running on unrelated re-renders. - const savedForRef = React.useRef(null); React.useEffect(() => { mountedRef.current = true; @@ -363,27 +446,29 @@ export function EncryptedBackupCreator({ if (!pendingPassphrase) return; let cancelled = false; const start = () => { - if (cancelled || !mountedRef.current) return; + if (cancelled) return; + // Completions dispatch unguarded: with a host-owned session the KDF + // may finish while this component is unmounted (user navigated Back), + // and the result must still land in the session. Dispatching to an + // unmounted private session is a safe no-op. dispatch({ type: "encrypt-started", passphrase: pendingPassphrase }); void createNcryptsecBackup(pendingPassphrase) .then((ncryptsec) => { - if (mountedRef.current) - dispatch({ - type: "encrypt-succeeded", - passphrase: pendingPassphrase, - ncryptsec, - }); + dispatch({ + type: "encrypt-succeeded", + passphrase: pendingPassphrase, + ncryptsec, + }); }) .catch((err: unknown) => { - if (mountedRef.current) - dispatch({ - type: "encrypt-failed", - passphrase: pendingPassphrase, - message: - err instanceof Error - ? err.message - : "Failed to encrypt your key.", - }); + dispatch({ + type: "encrypt-failed", + passphrase: pendingPassphrase, + message: + err instanceof Error + ? err.message + : "Failed to encrypt your key.", + }); }); }; const timer = window.setTimeout( @@ -394,10 +479,13 @@ export function EncryptedBackupCreator({ cancelled = true; window.clearTimeout(timer); }; - }, [pendingPassphrase, skipDebounce]); + }, [dispatch, pendingPassphrase, skipDebounce]); // Download commit: fires once per committed blob, whether the commit was - // instant (encryption already done) or resolved a queued download. + // instant (encryption already done) or resolved a queued download. The flow + // only advances to the test view once the file is actually on disk — a + // canceled save dialog or a save failure rolls the commit back to the + // password form so "Download backup" can be clicked again. React.useEffect(() => { const ncryptsec = state.ncryptsec; if (!ncryptsec || savedForRef.current === ncryptsec) return; @@ -405,14 +493,22 @@ export function EncryptedBackupCreator({ onCreated?.(); setIsSaving(true); setSaveError(null); + const rollBack = () => { + savedForRef.current = null; + dispatch({ type: "back-to-password" }); + }; void saveNcryptsecCopy(ncryptsec) .then((path) => { - if (mountedRef.current && path) { + if (path) { setSavedPath(path); onSaved?.(path); + } else { + // User canceled the native save dialog — nothing was downloaded. + rollBack(); } }) .catch((err: unknown) => { + rollBack(); if (mountedRef.current) setSaveError( err instanceof Error ? err.message : "Failed to save your key.", @@ -421,7 +517,14 @@ export function EncryptedBackupCreator({ .finally(() => { if (mountedRef.current) setIsSaving(false); }); - }, [onCreated, onSaved, state.ncryptsec]); + }, [ + dispatch, + onCreated, + onSaved, + savedForRef, + setSavedPath, + state.ncryptsec, + ]); const handleSaveCopy = React.useCallback(async () => { if (!state.ncryptsec || isSaving) return; @@ -441,19 +544,29 @@ export function EncryptedBackupCreator({ } finally { if (mountedRef.current) setIsSaving(false); } - }, [isSaving, onSaved, state.ncryptsec]); + }, [isSaving, onSaved, setSavedPath, state.ncryptsec]); + + const { setVerified, test, setTest } = session; + const handleVerified = React.useCallback(() => { + setVerified(true); + onVerified?.(); + }, [onVerified, setVerified]); const issue = passphraseIssue(state.passphrase); - if (state.ncryptsec) { + // The test view requires a successful save, not just a committed blob — + // while the native save dialog is open the password form stays put. + if (state.ncryptsec && savedPath) { return (
void handleSaveCopy()} - onVerified={onVerified} + onVerified={handleVerified} passphrase={state.passphrase} + progress={test} saveError={saveError} savedPath={savedPath} variant={variant} @@ -524,12 +637,21 @@ export function EncryptedBackupCreator({

) : null} + {saveError ? ( +

+ {saveError} +

+ ) : null} + {(() => { // Absolute spinner: signals the background encryption without // shifting the centered button while it appears and disappears. const createButton = (
- {isEncrypting(state) || state.downloadPending ? ( + {isEncrypting(state) || state.downloadPending || isSaving ? ( dispatch({ type: "download-clicked" })} type="button" > - {state.downloadPending ? : "Backup key"} + {state.downloadPending ? ( + + ) : ( + "Download backup" + )}
); diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index bdeaaa530..9b4e33ef7 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -11,6 +11,10 @@ import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { BackupStep } from "./BackupStep"; import { DefaultConfigStep } from "./DefaultConfigStep"; import { DownloadKeyStep } from "./DownloadKeyStep"; +import { + backupSessionToPasswordEntry, + useEncryptedBackupSession, +} from "./EncryptedBackupCreator"; import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog"; import { LandingBees } from "./LandingBees"; import { NostrKeyImportForm } from "./NostrKeyImportForm"; @@ -67,6 +71,9 @@ export function MachineOnboardingFlow({ null, ); const [readyRuntimeIds, setReadyRuntimeIds] = React.useState([]); + // Owned here (not by DownloadKeyStep) so Back navigation — which unmounts + // the step — keeps the created Keycase, entered password, and test progress. + const backupSession = useEncryptedBackupSession(); const handleReadyRuntimeIdsChange = React.useCallback( (runtimeIds: readonly string[]) => { setReadyRuntimeIds(Array.from(new Set(runtimeIds))); @@ -244,12 +251,22 @@ export function MachineOnboardingFlow({ direction="forward" onBack={() => setPage("backup")} onNext={() => setPage("setup")} + session={backupSession} /> ) : page === "setup" ? ( - setPage(identityWasImported ? "key-import" : "backup"), + // Fresh-key users return to the "Backup your key with a + // password" form (not the test flow they may have finished); + // imported keys skip that step entirely. + back: () => { + if (identityWasImported) { + setPage("key-import"); + return; + } + backupSessionToPasswordEntry(backupSession); + setPage("download"); + }, next: (runtimeIds) => { const ids = Array.from(runtimeIds); setReadyRuntimeIds(ids); diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts index dfaf9269c..3994c555a 100644 --- a/desktop/tests/e2e/onboarding-backup.spec.ts +++ b/desktop/tests/e2e/onboarding-backup.spec.ts @@ -148,9 +148,9 @@ test("download happy path: generated password, encrypt, native save, Next", asyn await page.getByTestId("encrypted-backup-create").click(); - // Download commits the blob and hands over to the "Now, test your backup" - // flow: a select-file button (dropzone while dragging) for the saved file, - // then the password to unlock it. + // Only a successful save (the mock "picks" a path) advances to the + // "Now, test your backup" flow: a select-file button (dropzone while + // dragging) for the saved file, then the password to unlock it. await expect( page.getByRole("heading", { name: "Now, test your backup" }), ).toBeVisible(); @@ -160,9 +160,9 @@ test("download happy path: generated password, encrypt, native save, Next", asyn "identity.ncryptsec", ); - // Until the test passes, Next stays disabled and Skip remains the escape - // hatch. - await expect(page.getByTestId("onboarding-next")).toBeDisabled(); + // Until the test passes there is no Next at all — Skip is the only way + // forward. + await expect(page.getByTestId("onboarding-next")).toHaveCount(0); await expect(page.getByTestId("onboarding-skip")).toBeVisible(); await waitForAnimations(page); @@ -232,6 +232,41 @@ test("download step Back returns to the backup chooser", async ({ page }) => { await expect(page.getByTestId("onboarding-next")).toBeVisible(); }); +test("test-view Back returns to the password form with the password intact", async ({ + page, +}) => { + await enterMachineBackup(page); + await page.getByTestId("onboarding-next").click(); + await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); + + const input = page.getByTestId("backup-passphrase-input"); + await input.fill("mock-horse-battery-staple"); + await page.getByTestId("encrypted-backup-create").click(); + await expect( + page.getByRole("heading", { name: "Now, test your backup" }), + ).toBeVisible(); + + // Back from the test view rolls back to the password form (same step), + // keeping the entered password; only from the form does Back leave the + // step. + await page.getByTestId("onboarding-back").click(); + await expect( + page.getByRole("heading", { name: "Backup your key with a password" }), + ).toBeVisible(); + await expect(input).toHaveValue("mock-horse-battery-staple"); + + // Re-downloading runs the ceremony again instantly from the cached + // encryption. + await page.getByTestId("encrypted-backup-create").click(); + await expect( + page.getByRole("heading", { name: "Now, test your backup" }), + ).toBeVisible(); + + await page.getByTestId("onboarding-back").click(); + await page.getByTestId("onboarding-back").click(); + await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); +}); + test("typed password requires 12 characters", async ({ page }) => { await enterMachineBackup(page); await page.getByTestId("onboarding-next").click();