feat(onboarding): durable backup session with save-gated test flow

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 <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-07-29 00:36:50 -07:00
parent 05d09373d1
commit 418c5f511c
8 changed files with 365 additions and 90 deletions
@@ -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.
@@ -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,
};
}
}
@@ -297,11 +297,6 @@ export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) {
>
Back
</Button>
<p className="text-xs text-foreground/50">
You can back up your key anytime in Settings &rarr; Profile &rarr;
Identity.
</p>
</OnboardingFooter>
) : null}
</OnboardingSlideTransition>
@@ -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<React.SetStateAction<BackupTestProgress>>;
/** 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<BackupTestStage>("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<string | null>(null);
const [fileError, setFileError] = React.useState<string | null>(null);
const [attempt, setAttempt] = React.useState("");
const [isRevealed, setIsRevealed] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const passwordInputRef = React.useRef<HTMLInputElement | null>(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"
@@ -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<HTMLElement | null>(null);
@@ -73,8 +84,7 @@ export function DownloadKeyStep({
<EncryptedBackupCreator
createButtonClassName={ONBOARDING_PRIMARY_CTA_CLASS}
createButtonPortal={createButtonSlot}
onCreated={() => setHasCreated(true)}
onVerified={() => setHasVerified(true)}
session={session}
variant="spotlight"
/>
</div>
@@ -85,30 +95,28 @@ export function DownloadKeyStep({
<OnboardingFooter>
{hasCreated ? (
/* Relative row keeps Next truly centered while Skip hangs off its
right edge without shifting the center. */
<div className="relative flex items-center justify-center">
hasVerified ? (
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-next"
disabled={!hasVerified}
onClick={onNext}
type="button"
>
Next
</Button>
{hasVerified ? null : (
<Button
className="absolute left-full ml-3 h-9 animate-in whitespace-nowrap rounded-full px-6 fade-in fill-mode-backwards [animation-delay:1000ms] animation-duration-[500ms] hover:bg-foreground/10 motion-reduce:animate-none"
data-testid="onboarding-skip"
onClick={onNext}
type="button"
variant="ghost"
>
Skip for now
</Button>
)}
</div>
) : (
/* No disabled Next while the test is unfinished — skipping is
the only way forward until verification succeeds. */
<Button
className="h-9 whitespace-nowrap rounded-full px-6 hover:bg-foreground/10"
data-testid="onboarding-skip"
onClick={onNext}
type="button"
variant="ghost"
>
Skip for now
</Button>
)
) : (
/* 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({
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="onboarding-back"
onClick={onBack}
onClick={
// From the test view, Back first returns to the password form;
// only from the form does it leave the step entirely.
hasCreated ? () => backupSessionToPasswordEntry(session) : onBack
}
type="button"
variant="ghost"
>
Back
</Button>
{hasCreated ? null : (
<p className="text-xs text-foreground/50">
You can back up your key anytime in Settings &rarr; Profile &rarr;
Identity.
</p>
)}
</OnboardingFooter>
</OnboardingSlideTransition>
);
@@ -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<EncryptedBackupEvent>;
/**
* 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<React.SetStateAction<boolean>>;
savedPath: string | null;
setSavedPath: React.Dispatch<React.SetStateAction<string | null>>;
/** The committed blob a save was already kicked off for (save-once guard). */
savedForRef: React.MutableRefObject<string | null>;
test: BackupTestProgress;
setTest: React.Dispatch<React.SetStateAction<BackupTestProgress>>;
};
/** 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<string | null>(null);
const savedForRef = React.useRef<string | null>(null);
const [test, setTest] = React.useState<BackupTestProgress>(
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<string | null>(null);
const [saveError, setSaveError] = React.useState<string | null>(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<string | null>(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 (
<div data-testid="encrypted-backup-result">
<BackupTestFlow
isSaving={isSaving}
ncryptsec={state.ncryptsec}
onProgressChange={setTest}
onSaveCopy={() => void handleSaveCopy()}
onVerified={onVerified}
onVerified={handleVerified}
passphrase={state.passphrase}
progress={test}
saveError={saveError}
savedPath={savedPath}
variant={variant}
@@ -524,12 +637,21 @@ export function EncryptedBackupCreator({
</p>
) : null}
{saveError ? (
<p
className="text-center text-sm text-destructive"
data-testid="encrypted-backup-save-error"
>
{saveError}
</p>
) : null}
{(() => {
// Absolute spinner: signals the background encryption without
// shifting the centered button while it appears and disappears.
const createButton = (
<div className="relative">
{isEncrypting(state) || state.downloadPending ? (
{isEncrypting(state) || state.downloadPending || isSaving ? (
<Spinner
aria-label="Encrypting your key"
className="absolute right-full top-1/2 mr-3 h-4 w-4 -translate-y-1/2 border-2"
@@ -539,11 +661,15 @@ export function EncryptedBackupCreator({
<Button
className={cn("h-9 rounded-full px-6", createButtonClassName)}
data-testid="encrypted-backup-create"
disabled={downloadDisabled(state)}
disabled={downloadDisabled(state) || isSaving}
onClick={() => dispatch({ type: "download-clicked" })}
type="button"
>
{state.downloadPending ? <PendingDownloadTicker /> : "Backup key"}
{state.downloadPending ? (
<PendingDownloadTicker />
) : (
"Download backup"
)}
</Button>
</div>
);
@@ -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<string[]>([]);
// 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" ? (
<SetupStep
actions={{
back: () =>
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);
+41 -6
View File
@@ -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();