mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(onboarding): replace post-download blob view with Test your backup flow
- Add BackupTestFlow.tsx: after downloading the Keycase, the step becomes an explicit "Test your backup" view — a large dropzone for the downloaded file, then a password prompt that succeeds the instant the password is typed completely, ending in a spring-in check badge with an emoji burst celebration (skipped under reduced motion) - Verification is instant and KDF-free: the dropped file's bytes are compared to the committed ncryptsec blob and the typed password to the in-memory passphrase, so no key material beyond what the creator already held enters the component - Wrong files get inline errors (Keycase-but-different vs not-a-Keycase); full-length wrong passwords get a mismatch hint; a "Re-download backup" button re-opens the native save dialog and "Use a different file" returns to the dropzone - EncryptedBackupCreator.tsx: swap the masked-ncryptsec result view (NsecMaskedDisplay + "Save a copy…") for BackupTestFlow - DownloadKeyStep.tsx: header/subtitle now conditional — "Backup your key with a password" while creating, "Test your backup" once created; remove the bracketed shell-box visual, the keep-this-file-private hints, and the "back up anytime in Settings" footer note - BackupStep.tsx / MachineOnboardingFlow.tsx: chooser footer "Next" now always leads into the download step (skip lives there as "Skip for now") - Update onboarding-backup.spec.ts happy path to drive the new flow (wrong file, right file, wrong password, success, Next) with screenshots; align docked-CTA spec and passThroughBackupStep helper with the new navigation; allowlist BackupTestFlow.tsx in the ncryptsec source scan Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -34,7 +34,7 @@ let introPlayed = false;
|
||||
const REVEAL_ANIMATION_CLASS =
|
||||
"animate-in fade-in duration-700 motion-reduce:animate-none";
|
||||
|
||||
/** Saving a Keycase is recommended, never required to continue onboarding. */
|
||||
/** Viewing the key never blocks onboarding — Next is always actionable. */
|
||||
export function backupNextDisabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -44,22 +44,16 @@ type BackupStepProps = {
|
||||
onBack: () => void;
|
||||
/** Advances to the dedicated "Download your key" onboarding step. */
|
||||
onDownload: () => void;
|
||||
onNext: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Onboarding backup step — shows the freshly created key and offers two ways
|
||||
* to back it up without blocking setup: the encrypted download flow (its own
|
||||
* onboarding step, via `onDownload`) or a direct clipboard copy destined for
|
||||
* a password manager. The raw key is fetched only when the user explicitly
|
||||
* Onboarding backup step — shows the freshly created key and offers a direct
|
||||
* clipboard copy destined for a password manager. Next leads into the
|
||||
* encrypted download step (its own onboarding page, via `onDownload`), which
|
||||
* is skippable there. The raw key is fetched only when the user explicitly
|
||||
* clicks Copy or Reveal, and is never held in state before that.
|
||||
*/
|
||||
export function BackupStep({
|
||||
direction,
|
||||
onBack,
|
||||
onDownload,
|
||||
onNext,
|
||||
}: BackupStepProps) {
|
||||
export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) {
|
||||
const [created, setCreated] = React.useState(introPlayed);
|
||||
const [copyState, setCopyState] = React.useState<
|
||||
"idle" | "copying" | "copied"
|
||||
@@ -284,28 +278,15 @@ export function BackupStep({
|
||||
|
||||
{created ? (
|
||||
<OnboardingFooter className={REVEAL_ANIMATION_CLASS}>
|
||||
{/* Relative row keeps the primary CTA truly centered while Skip
|
||||
hangs off its right edge without shifting the center. */}
|
||||
<div className="relative flex items-center justify-center">
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="backup-option-download"
|
||||
onClick={onDownload}
|
||||
type="button"
|
||||
>
|
||||
Backup your key
|
||||
</Button>
|
||||
<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-next"
|
||||
disabled={backupNextDisabled()}
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="onboarding-next"
|
||||
disabled={backupNextDisabled()}
|
||||
onClick={onDownload}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import { Check, Eye, EyeOff, FileKey2, FileUp } from "lucide-react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
|
||||
type BackupTestStage = "drop" | "password" | "success";
|
||||
|
||||
type BackupTestFlowProps = {
|
||||
/** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */
|
||||
variant?: "spotlight" | "boxed";
|
||||
/** The exact committed `ncryptsec1…` blob the downloaded file contains. */
|
||||
ncryptsec: string;
|
||||
/** The passphrase the backup was encrypted under. */
|
||||
passphrase: string;
|
||||
/** Re-open the native save dialog for another copy of the backup file. */
|
||||
onSaveCopy: () => void;
|
||||
isSaving: boolean;
|
||||
savedPath: string | null;
|
||||
saveError: string | null;
|
||||
/** Fired once when the user completes the test successfully. */
|
||||
onVerified?: () => void;
|
||||
};
|
||||
|
||||
const BURST_EMOJIS = ["🎉", "✨", "🐝", "🍯", "🔑", "💛"] as const;
|
||||
const BURST_PARTICLE_COUNT = 18;
|
||||
|
||||
type BurstParticle = {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
emoji: string;
|
||||
delay: number;
|
||||
scale: number;
|
||||
rotate: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* One-shot radial emoji burst behind the success badge. Purely decorative —
|
||||
* skipped entirely under reduced motion.
|
||||
*/
|
||||
function SuccessBurst() {
|
||||
const particles = React.useMemo<BurstParticle[]>(
|
||||
() =>
|
||||
Array.from({ length: BURST_PARTICLE_COUNT }, (_, i) => {
|
||||
const angle =
|
||||
(i / BURST_PARTICLE_COUNT) * Math.PI * 2 + Math.random() * 0.5;
|
||||
const distance = 70 + Math.random() * 80;
|
||||
return {
|
||||
id: i,
|
||||
x: Math.cos(angle) * distance,
|
||||
y: Math.sin(angle) * distance,
|
||||
emoji: BURST_EMOJIS[i % BURST_EMOJIS.length],
|
||||
delay: Math.random() * 0.18,
|
||||
scale: 0.8 + Math.random() * 0.7,
|
||||
rotate: -120 + Math.random() * 240,
|
||||
};
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
|
||||
>
|
||||
{particles.map((particle) => (
|
||||
<motion.span
|
||||
animate={{
|
||||
x: particle.x,
|
||||
y: particle.y,
|
||||
opacity: 0,
|
||||
scale: particle.scale,
|
||||
rotate: particle.rotate,
|
||||
}}
|
||||
className="absolute text-xl"
|
||||
initial={{ x: 0, y: 0, opacity: 1, scale: 0.3, rotate: 0 }}
|
||||
key={particle.id}
|
||||
transition={{
|
||||
duration: 0.9,
|
||||
delay: particle.delay,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
{particle.emoji}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-download "Test your backup" flow: the user drops the file they just
|
||||
* downloaded onto a large dropzone, then retypes their password. Both checks
|
||||
* run instantly in memory — the dropped file's bytes must equal the committed
|
||||
* blob and the typed password must equal the passphrase that encrypted it —
|
||||
* so there is no second KDF run and no key material beyond what the creator
|
||||
* already held.
|
||||
*/
|
||||
export function BackupTestFlow({
|
||||
variant = "spotlight",
|
||||
ncryptsec,
|
||||
passphrase,
|
||||
onSaveCopy,
|
||||
isSaving,
|
||||
savedPath,
|
||||
saveError,
|
||||
onVerified,
|
||||
}: BackupTestFlowProps) {
|
||||
const reduceMotion = useReducedMotion() ?? false;
|
||||
const [stage, setStage] = React.useState<BackupTestStage>("drop");
|
||||
const [isDragActive, setIsDragActive] = React.useState(false);
|
||||
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);
|
||||
const mountedRef = React.useRef(true);
|
||||
const verifiedFiredRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (stage === "password") passwordInputRef.current?.focus();
|
||||
}, [stage]);
|
||||
|
||||
const succeed = React.useCallback(() => {
|
||||
setStage("success");
|
||||
if (!verifiedFiredRef.current) {
|
||||
verifiedFiredRef.current = true;
|
||||
onVerified?.();
|
||||
}
|
||||
}, [onVerified]);
|
||||
|
||||
const handleFile = React.useCallback(
|
||||
async (file: File) => {
|
||||
let text: string;
|
||||
try {
|
||||
text = await file.text();
|
||||
} catch {
|
||||
if (mountedRef.current) setFileError("Could not read that file.");
|
||||
return;
|
||||
}
|
||||
if (!mountedRef.current) return;
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === ncryptsec.trim()) {
|
||||
setFileName(file.name);
|
||||
setFileError(null);
|
||||
setStage("password");
|
||||
} else if (trimmed.toLowerCase().startsWith("ncryptsec1")) {
|
||||
setFileError(
|
||||
"That's a Keycase file, but not the one you just downloaded.",
|
||||
);
|
||||
} else {
|
||||
setFileError(
|
||||
"That doesn't look like your Keycase file. Drop the file you just downloaded.",
|
||||
);
|
||||
}
|
||||
},
|
||||
[ncryptsec],
|
||||
);
|
||||
|
||||
const handlePasswordChange = React.useCallback(
|
||||
(value: string) => {
|
||||
setAttempt(value);
|
||||
if (value === passphrase) succeed();
|
||||
},
|
||||
[passphrase, succeed],
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
if (stage === "success") {
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col items-center gap-4 py-4 text-center"
|
||||
data-testid="backup-test-success"
|
||||
>
|
||||
{reduceMotion ? null : <SuccessBurst />}
|
||||
<motion.div
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-primary-foreground"
|
||||
initial={reduceMotion ? false : { scale: 0, opacity: 0 }}
|
||||
transition={
|
||||
reduceMotion
|
||||
? { duration: 0 }
|
||||
: { type: "spring", stiffness: 380, damping: 18 }
|
||||
}
|
||||
>
|
||||
<Check aria-hidden="true" className="h-8 w-8" strokeWidth={3} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
|
||||
transition={
|
||||
reduceMotion ? { duration: 0 } : { delay: 0.15, duration: 0.35 }
|
||||
}
|
||||
>
|
||||
<p className="text-lg font-medium text-foreground">
|
||||
Your backup works!
|
||||
</p>
|
||||
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
|
||||
File and password verified. Keep them both somewhere safe — that's
|
||||
all you need to restore your identity.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full space-y-4",
|
||||
isSpotlight ? "max-w-140" : "max-w-125",
|
||||
)}
|
||||
data-testid="backup-test-flow"
|
||||
>
|
||||
{stage === "drop" ? (
|
||||
<>
|
||||
<input
|
||||
accept=".ncryptsec,text/plain"
|
||||
className="sr-only"
|
||||
data-testid="backup-test-file-input"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
// Allow re-selecting the same file after an error.
|
||||
event.target.value = "";
|
||||
if (file) void handleFile(file);
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
tabIndex={-1}
|
||||
type="file"
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed px-6 text-center transition-colors",
|
||||
isSpotlight ? "h-44" : "h-32",
|
||||
isDragActive
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-foreground/25 bg-background/40 hover:border-foreground/40 hover:bg-background/70",
|
||||
)}
|
||||
data-testid="backup-test-dropzone"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragLeave={() => setIsDragActive(false)}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragActive(true);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragActive(false);
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (file) void handleFile(file);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<FileUp
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-8 w-8 transition-colors",
|
||||
isDragActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Drop your backup file here
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
or click to browse for it
|
||||
</span>
|
||||
</button>
|
||||
{fileError ? (
|
||||
<p
|
||||
className="text-center text-sm text-destructive"
|
||||
data-testid="backup-test-file-error"
|
||||
>
|
||||
{fileError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Button
|
||||
className="h-8 gap-1.5 text-sm"
|
||||
data-testid="encrypted-backup-save-copy"
|
||||
disabled={isSaving}
|
||||
onClick={onSaveCopy}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{isSaving ? <Spinner className="h-3.5 w-3.5 border-2" /> : null}
|
||||
Re-download backup
|
||||
</Button>
|
||||
{savedPath ? (
|
||||
<p
|
||||
className="text-center text-xs text-muted-foreground"
|
||||
data-testid="encrypted-backup-saved-path"
|
||||
>
|
||||
Saved to {savedPath}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{saveError ? (
|
||||
<p className="text-center text-sm text-destructive">{saveError}</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center justify-center gap-2 text-sm text-foreground animate-in fade-in slide-in-from-bottom-1 duration-300 motion-reduce:animate-none"
|
||||
data-testid="backup-test-file-accepted"
|
||||
>
|
||||
<FileKey2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
<span className="max-w-70 truncate font-mono text-xs">
|
||||
{fileName}
|
||||
</span>
|
||||
<Check aria-hidden="true" className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<p className="text-center text-sm leading-6 text-muted-foreground">
|
||||
That's the one. Now type your password to prove you can unlock it.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Input
|
||||
aria-label="Backup password"
|
||||
autoComplete="off"
|
||||
className="h-10 bg-background pr-10 font-mono"
|
||||
data-testid="backup-test-password"
|
||||
onChange={(event) => handlePasswordChange(event.target.value)}
|
||||
placeholder="Your backup password"
|
||||
ref={passwordInputRef}
|
||||
type={isRevealed ? "text" : "password"}
|
||||
value={attempt}
|
||||
/>
|
||||
<Button
|
||||
aria-label={isRevealed ? "Hide password" : "Reveal password"}
|
||||
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
data-testid="backup-test-password-reveal-toggle"
|
||||
onClick={() => setIsRevealed((revealed) => !revealed)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{isRevealed ? (
|
||||
<EyeOff aria-hidden="true" className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye aria-hidden="true" className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{attemptWrong ? (
|
||||
<p
|
||||
className="absolute left-1 top-full mt-1 text-xs text-destructive animate-in fade-in duration-200 motion-reduce:animate-none"
|
||||
data-testid="backup-test-password-mismatch"
|
||||
>
|
||||
Not quite — check the password you saved.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
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("");
|
||||
setIsRevealed(false);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Use a different file
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
|
||||
@@ -12,49 +11,6 @@ import {
|
||||
} from "./OnboardingSlideTransition";
|
||||
import { EncryptedBackupCreator } from "./EncryptedBackupCreator";
|
||||
|
||||
/**
|
||||
* One half of the "[ ]" pair that clamps around the shell box once it
|
||||
* settles — the visual is the user working on the secure shell that
|
||||
* surrounds their key. Brackets close inward as they fade in.
|
||||
*/
|
||||
function ShellBracket({
|
||||
reduceMotion,
|
||||
side,
|
||||
visible,
|
||||
}: {
|
||||
reduceMotion: boolean;
|
||||
side: "left" | "right";
|
||||
visible: boolean;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
animate={
|
||||
visible
|
||||
? { opacity: 1, x: 0 }
|
||||
: { opacity: 0, x: side === "left" ? -10 : 10 }
|
||||
}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
// The bracket pair frames the h-16 (64px) box as a concentric 96px
|
||||
// square: each line sits 10px clear of the box on every side (plus
|
||||
// the 6px line itself), and the 2rem corner radius wraps the box's
|
||||
// rounded-2xl (16px) corners at a constant 16px offset. The vertical
|
||||
// line lives on the OUTER edge of the 32px-deep arm, so each bracket
|
||||
// pulls 16px back over the box (negative margin) to keep that line
|
||||
// 10px from the box edge.
|
||||
"h-24 w-8 shrink-0 border-black",
|
||||
side === "left"
|
||||
? "-mr-4 rounded-l-[2rem] border-y-[6px] border-l-[6px]"
|
||||
: "-ml-4 rounded-r-[2rem] border-y-[6px] border-r-[6px]",
|
||||
)}
|
||||
initial={false}
|
||||
transition={
|
||||
reduceMotion ? { duration: 0 } : { duration: 0.35, ease: "easeOut" }
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DownloadKeyStepProps = {
|
||||
direction: OnboardingTransitionDirection;
|
||||
onBack: () => void;
|
||||
@@ -73,8 +29,6 @@ export function DownloadKeyStep({
|
||||
onNext,
|
||||
}: DownloadKeyStepProps) {
|
||||
const reduceMotion = useReducedMotion() ?? false;
|
||||
// True once the shell box has landed; gates the closing brackets.
|
||||
const [shellSettled, setShellSettled] = React.useState(false);
|
||||
// True once the encrypted payload exists — the create button (living in the
|
||||
// footer's primary slot) disappears with the form, so Next takes its place.
|
||||
const [hasCreated, setHasCreated] = React.useState(false);
|
||||
@@ -93,41 +47,17 @@ export function DownloadKeyStep({
|
||||
{/* Plain string concat: cn()'s tailwind-merge misreads the custom
|
||||
text-title size token as conflicting with text-foreground. */}
|
||||
<h1 className="text-title font-normal text-foreground">
|
||||
Backup your key
|
||||
{hasCreated ? "Test your backup" : "Backup your key with a password"}
|
||||
</h1>
|
||||
<p className="mt-5 text-sm leading-6 text-foreground/80">
|
||||
Keep the downloaded file private — you need both it and your password
|
||||
to restore your identity. Save the password somewhere safe; Buzz
|
||||
cannot reset it if lost.
|
||||
{hasCreated
|
||||
? "Make sure your backup works: drop the file you just saved and unlock it with your password."
|
||||
: "Keep the downloaded file private — you need both it and your password to restore your identity. Save the backup password somewhere safe; Buzz cannot reset it if lost."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-[1040px] flex-1 flex-col justify-center py-10">
|
||||
<div className="w-full">
|
||||
<div className="mb-6 flex items-center justify-center">
|
||||
<ShellBracket
|
||||
reduceMotion={reduceMotion}
|
||||
side="left"
|
||||
visible={shellSettled || reduceMotion}
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="h-16 w-16 rounded-2xl bg-white"
|
||||
data-testid="backup-key-shell"
|
||||
initial={reduceMotion ? false : { opacity: 0, scale: 0.8 }}
|
||||
onAnimationComplete={() => setShellSettled(true)}
|
||||
transition={
|
||||
reduceMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.45, ease: [0.22, 1, 0.36, 1] }
|
||||
}
|
||||
/>
|
||||
<ShellBracket
|
||||
reduceMotion={reduceMotion}
|
||||
side="right"
|
||||
visible={shellSettled || reduceMotion}
|
||||
/>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 12 }}
|
||||
@@ -158,11 +88,24 @@ export function DownloadKeyStep({
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<div
|
||||
className="flex justify-center"
|
||||
data-testid="onboarding-create-slot"
|
||||
ref={setCreateButtonSlot}
|
||||
/>
|
||||
/* Relative row keeps the Download CTA truly centered while Skip
|
||||
hangs off its right edge without shifting the center. */
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div
|
||||
className="flex justify-center"
|
||||
data-testid="onboarding-create-slot"
|
||||
ref={setCreateButtonSlot}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<Button
|
||||
@@ -174,11 +117,6 @@ export function DownloadKeyStep({
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-foreground/50">
|
||||
You can back up your key anytime in Settings → Profile →
|
||||
Identity.
|
||||
</p>
|
||||
</OnboardingFooter>
|
||||
</OnboardingSlideTransition>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
initialEncryptedBackupState,
|
||||
MIN_PASSPHRASE_LEN,
|
||||
} from "../lib/encryptedBackup";
|
||||
import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
|
||||
import { BackupTestFlow } from "./BackupTestFlow";
|
||||
|
||||
/** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */
|
||||
const MIN_GENERATED_WORDS = 3;
|
||||
@@ -440,46 +440,20 @@ export function EncryptedBackupCreator({
|
||||
}
|
||||
}, [isSaving, onSaved, state.ncryptsec]);
|
||||
|
||||
const isSpotlight = variant === "spotlight";
|
||||
const issue = passphraseIssue(state.passphrase);
|
||||
|
||||
if (state.ncryptsec) {
|
||||
return (
|
||||
<div className="space-y-4" data-testid="encrypted-backup-result">
|
||||
<NsecMaskedDisplay
|
||||
kind="ncryptsec"
|
||||
nsec={state.ncryptsec}
|
||||
variant={isSpotlight ? "bare" : "boxed"}
|
||||
<div data-testid="encrypted-backup-result">
|
||||
<BackupTestFlow
|
||||
isSaving={isSaving}
|
||||
ncryptsec={state.ncryptsec}
|
||||
onSaveCopy={() => void handleSaveCopy()}
|
||||
passphrase={state.passphrase}
|
||||
saveError={saveError}
|
||||
savedPath={savedPath}
|
||||
variant={variant}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button
|
||||
className="h-8 gap-1.5 text-sm"
|
||||
data-testid="encrypted-backup-save-copy"
|
||||
disabled={isSaving}
|
||||
onClick={() => void handleSaveCopy()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{isSaving ? <Spinner className="h-3.5 w-3.5 border-2" /> : null}
|
||||
Save a copy…
|
||||
</Button>
|
||||
{savedPath ? (
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid="encrypted-backup-saved-path"
|
||||
>
|
||||
Saved to {savedPath}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{saveError ? (
|
||||
<p className="text-center text-sm text-destructive">{saveError}</p>
|
||||
) : null}
|
||||
<p className="text-center text-xs leading-5 text-muted-foreground">
|
||||
Keep this file private. You need both the file and its password to
|
||||
restore your identity. Buzz cannot reset the password.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -565,7 +539,7 @@ export function EncryptedBackupCreator({
|
||||
onClick={() => dispatch({ type: "download-clicked" })}
|
||||
type="button"
|
||||
>
|
||||
{state.downloadPending ? <PendingDownloadTicker /> : "Download"}
|
||||
{state.downloadPending ? <PendingDownloadTicker /> : "Backup key"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -238,7 +238,6 @@ export function MachineOnboardingFlow({
|
||||
direction="forward"
|
||||
onBack={() => setPage("identity")}
|
||||
onDownload={() => setPage("download")}
|
||||
onNext={() => setPage("setup")}
|
||||
/>
|
||||
) : page === "download" ? (
|
||||
<DownloadKeyStep
|
||||
|
||||
@@ -26,6 +26,7 @@ const ALLOWLIST = [
|
||||
"features/onboarding/lib/keyImportInput.ts",
|
||||
"features/onboarding/lib/keyImportInput.test.mjs",
|
||||
"features/onboarding/ui/BackupStep.tsx",
|
||||
"features/onboarding/ui/BackupTestFlow.tsx",
|
||||
"features/onboarding/ui/EncryptedBackupCreator.tsx",
|
||||
"features/onboarding/ui/NostrKeyImportForm.tsx",
|
||||
"features/onboarding/ui/NsecMaskedDisplay.tsx",
|
||||
|
||||
@@ -21,6 +21,12 @@ async function invokedCommands(page: import("@playwright/test").Page) {
|
||||
|
||||
const SHOTS = "test-results/screenshots-onboarding";
|
||||
|
||||
// Mirrors the mock bridge's MOCK_NCRYPTSEC (e2eBridge.ts): the blob the
|
||||
// mocked `create_ncryptsec_backup` returns, i.e. the "downloaded file"
|
||||
// contents the test-your-backup dropzone expects.
|
||||
const MOCK_NCRYPTSEC =
|
||||
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
|
||||
|
||||
test("backup step appears on fresh-key path after profile submit", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -82,9 +88,11 @@ test("chooser shows masked key; reveal and copy fetch it explicitly", async ({
|
||||
.toContain("copy_text_to_clipboard");
|
||||
expect(await invokedCommands(page)).toContain("get_nsec");
|
||||
|
||||
// Backup is recommended, never required.
|
||||
// Next leads into the download step, where backup stays skippable.
|
||||
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
await page.getByTestId("onboarding-skip").click();
|
||||
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -99,9 +107,9 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
|
||||
}) => {
|
||||
await enterMachineBackup(page);
|
||||
|
||||
// The download flow is its own onboarding step behind the footer CTA.
|
||||
// The download flow is its own onboarding step behind the footer's Next.
|
||||
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
|
||||
await page.getByTestId("backup-option-download").click();
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
|
||||
// The password field starts empty; the create button sits in the footer's
|
||||
@@ -110,6 +118,7 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
|
||||
await expect(input).toHaveValue("");
|
||||
await expect(page.getByTestId("encrypted-backup-create")).toBeDisabled();
|
||||
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
|
||||
await expect(page.getByTestId("onboarding-skip")).toBeVisible();
|
||||
|
||||
// The inset refresh icon opens the generator popover and immediately
|
||||
// fills the field (mock default: 3 words, spaces).
|
||||
@@ -139,20 +148,56 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
|
||||
|
||||
await page.getByTestId("encrypted-backup-create").click();
|
||||
|
||||
// The locally created blob stays masked; the portable save action is explicit.
|
||||
const blob = page.getByTestId("ncryptsec-value");
|
||||
await expect(blob).toBeVisible();
|
||||
await expect(blob).toHaveCSS("filter", /blur/);
|
||||
await page.getByTestId("ncryptsec-reveal-toggle").click();
|
||||
await expect(blob).toContainText("ncryptsec1");
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/04-backup-download-encrypted.png` });
|
||||
|
||||
// Download commits the blob and hands over to the "Test your backup"
|
||||
// flow: a dropzone for the saved file, then the password to unlock it.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Test your backup" }),
|
||||
).toBeVisible();
|
||||
const dropzone = page.getByTestId("backup-test-dropzone");
|
||||
await expect(dropzone).toBeVisible();
|
||||
await expect(page.getByTestId("encrypted-backup-saved-path")).toContainText(
|
||||
"identity.ncryptsec",
|
||||
);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/04-backup-test-dropzone.png` });
|
||||
|
||||
// A wrong file is rejected with an inline error; the dropzone stays.
|
||||
await page.getByTestId("backup-test-file-input").setInputFiles({
|
||||
name: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("not a keycase"),
|
||||
});
|
||||
await expect(page.getByTestId("backup-test-file-error")).toBeVisible();
|
||||
|
||||
// The freshly downloaded file advances to the password check.
|
||||
await page.getByTestId("backup-test-file-input").setInputFiles({
|
||||
name: "identity.ncryptsec",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from(MOCK_NCRYPTSEC),
|
||||
});
|
||||
const password = page.getByTestId("backup-test-password");
|
||||
await expect(password).toBeVisible();
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/05-backup-test-password.png` });
|
||||
|
||||
// A full-length wrong password shows the mismatch hint, never success.
|
||||
await password.fill("mock-horse-battery-staplX");
|
||||
await expect(page.getByTestId("backup-test-password-mismatch")).toBeVisible();
|
||||
await expect(page.getByTestId("backup-test-success")).toHaveCount(0);
|
||||
|
||||
// Typing the password completely succeeds without any extra click.
|
||||
await password.fill("mock-horse-battery-staple");
|
||||
await expect(page.getByTestId("backup-test-success")).toBeVisible();
|
||||
|
||||
// The celebration is driven by motion's rAF loop, which
|
||||
// `waitForAnimations` (WAAPI-only) cannot observe — hold until the badge
|
||||
// and copy have faded in before capturing.
|
||||
await page.waitForTimeout(1200);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/06-backup-test-success.png` });
|
||||
|
||||
// The download path must never have fetched the raw key.
|
||||
const commands = await invokedCommands(page);
|
||||
expect(commands).not.toContain("get_nsec");
|
||||
@@ -166,21 +211,22 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
|
||||
test("download step Back returns to the backup chooser", async ({ page }) => {
|
||||
await enterMachineBackup(page);
|
||||
|
||||
await page.getByTestId("backup-option-download").click();
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
await expect(page.getByTestId("backup-passphrase-input")).toBeVisible();
|
||||
// The chooser's footer CTA belongs to the previous step.
|
||||
await expect(page.getByTestId("backup-option-download")).toHaveCount(0);
|
||||
// The chooser's footer Next belongs to the previous step; the download
|
||||
// step only mounts its own Next once the backup exists.
|
||||
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("onboarding-back").click();
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
await expect(page.getByTestId("backup-key-value")).toBeVisible();
|
||||
await expect(page.getByTestId("backup-option-download")).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-next")).toBeVisible();
|
||||
});
|
||||
|
||||
test("typed password requires 12 characters", async ({ page }) => {
|
||||
await enterMachineBackup(page);
|
||||
await page.getByTestId("backup-option-download").click();
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
|
||||
const create = page.getByTestId("encrypted-backup-create");
|
||||
await expect(create).toBeDisabled(); // empty field
|
||||
@@ -233,9 +279,12 @@ test("reveal shows inline error when get_nsec fails and Next still advances", as
|
||||
await page.getByTestId("backup-key-reveal-toggle").click();
|
||||
|
||||
await expect(page.getByTestId("backup-copy-error")).toBeVisible();
|
||||
// Keychain failure does not trap the user.
|
||||
// Keychain failure does not trap the user: Next still advances into the
|
||||
// download step, and Skip there continues to setup.
|
||||
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
await page.getByTestId("onboarding-skip").click();
|
||||
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -68,7 +68,10 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOT_DIR}/02b-backup-revealed.png` });
|
||||
|
||||
// Next leads into the download step; Skip there continues to setup.
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
await page.getByTestId("onboarding-skip").click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Set up your agent harnesses" }),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -16,10 +16,12 @@ export async function seedActiveIdentity(
|
||||
);
|
||||
}
|
||||
|
||||
/** Navigate through the backup step (fresh-key path). */
|
||||
/** Navigate through the backup steps (fresh-key path). */
|
||||
export async function passThroughBackupStep(page: Page) {
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
// Backing up is recommended, never required — "Skip for now" advances
|
||||
// straight to setup without visiting the "Backup your key" step.
|
||||
// Next always leads into the "Backup your key" step; backing up is
|
||||
// recommended, never required — "Skip for now" there advances to setup.
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
await page.getByTestId("onboarding-skip").click();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user