+ ) : (
+ /* 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 (
+ ) : null}
+
{(() => {
// Absolute spinner: signals the background encryption without
// shifting the centered button while it appears and disappears.
const createButton = (
);
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();