mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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 → Profile →
|
||||
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 → Profile →
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user