feat(desktop): refine optional backup verification

- Present backup verification as an optional post-download step with contextual headings and a Skip for now action.
- Match the verification password field to the backup password input and move its primary action into the onboarding footer.
- Add an upward-pulsing file-to-password illustration with reduced-motion support and a stronger selected-file affordance.
- Remove saved filesystem paths from the verification view to avoid exposing local details.
- Reset encrypted-backup state whenever users re-enter the creation flow.
- Confirm before leaving only when an unfinished pre-download password or encryption would be discarded.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-07-29 23:27:21 -07:00
committed by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent 09bc460196
commit 9a7faa9425
4 changed files with 286 additions and 140 deletions

View File

@@ -1,6 +1,7 @@
import { Check, Eye, EyeOff, FileKey2, FileUp } from "lucide-react";
import { Check, CircleHelp, Eye, EyeOff, FileKey2, FileUp } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { createPortal } from "react-dom";
import {
verifyNcryptsecBackup,
@@ -15,7 +16,6 @@ import { Spinner } from "@/shared/ui/spinner";
import {
ONBOARDING_SECURITY_PRIMARY_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
ONBOARDING_SECURITY_ICON_CLASS,
} from "./OnboardingChrome";
type BackupTestStage = "drop" | "password" | "success";
@@ -56,8 +56,9 @@ type BackupTestFlowProps = {
/** Re-open the native save dialog for another copy of the backup file. */
onSaveCopy?: () => void;
isSaving?: boolean;
savedPath?: string | null;
saveError?: string | null;
/** Optional onboarding footer target for the verification CTA. */
verifyButtonPortal?: HTMLElement | null;
/** Host-owned progress so it survives this component unmounting. */
progress: BackupTestProgress;
onProgressChange: React.Dispatch<React.SetStateAction<BackupTestProgress>>;
@@ -67,6 +68,22 @@ type BackupTestFlowProps = {
const BURST_EMOJIS = ["🎉", "✨", "🐝", "🍯", "🔑", "💛"] as const;
const BURST_PARTICLE_COUNT = 18;
const VERIFICATION_CONNECTOR_DOTS = [
"verification-dot-1",
"verification-dot-2",
"verification-dot-3",
"verification-dot-4",
] as const;
const VERIFICATION_DOT_ANIMATION = {
opacity: [0.35, 1, 0.35],
scale: [0.85, 1.25, 0.85],
};
const VERIFICATION_DOT_TRANSITION = {
duration: 0.7,
ease: "easeInOut" as const,
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 1.2,
};
type BurstParticle = {
id: number;
@@ -132,6 +149,43 @@ function SuccessBurst() {
);
}
function VerificationConnector({
delayOffset,
reduceMotion,
}: {
delayOffset: number;
reduceMotion: boolean;
}) {
return (
<div
aria-hidden
className="my-5 flex h-14 flex-col items-center justify-between py-1"
>
{VERIFICATION_CONNECTOR_DOTS.map((dot, index) => (
<motion.span
animate={reduceMotion ? undefined : VERIFICATION_DOT_ANIMATION}
className="block size-1.5 rounded-full bg-foreground/65"
initial={reduceMotion ? false : { opacity: 0.35, scale: 0.85 }}
key={dot}
transition={
reduceMotion
? undefined
: {
...VERIFICATION_DOT_TRANSITION,
delay:
(VERIFICATION_CONNECTOR_DOTS.length -
1 -
index +
delayOffset) *
0.16,
}
}
/>
))}
</div>
);
}
/**
* "Test your backup" flow: the user drops a backup file onto a large
* dropzone, then enters its password. Verification is a real NIP-49 decrypt
@@ -143,8 +197,8 @@ export function BackupTestFlow({
expectedNcryptsec,
onSaveCopy,
isSaving = false,
savedPath,
saveError,
verifyButtonPortal,
progress,
onProgressChange,
onVerified,
@@ -443,14 +497,6 @@ export function BackupTestFlow({
{isSaving ? <Spinner className="h-4 w-4 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>
) : null}
{saveError ? (
@@ -465,119 +511,142 @@ export function BackupTestFlow({
key="password"
transition={{ duration: reduceMotion ? 0 : 0.3, ease: "easeOut" }}
>
<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 enter your password to prove you can unlock it.
</p>
<div className="relative">
<Input
aria-label="Backup password"
autoComplete="off"
className={cn(
"font-mono",
isSpotlight
? "h-14 rounded-none border-0 bg-transparent px-14 text-center text-lg shadow-none focus-visible:ring-0"
: "h-10 bg-background pr-10",
)}
data-testid="backup-test-password"
disabled={isVerifying}
onChange={(event) => setAttempt(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleVerify();
}
}}
placeholder="Your backup password"
ref={passwordInputRef}
type={isRevealed ? "text" : "password"}
value={attempt}
/>
<Button
aria-label={isRevealed ? "Hide password" : "Reveal password"}
className={cn(
"absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground",
isSpotlight && ONBOARDING_SECURITY_ICON_CLASS,
)}
data-testid="backup-test-password-reveal-toggle"
disabled={isVerifying}
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>
{error ? (
<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-error"
role="alert"
{(() => {
const fileRow = (
<div
className="flex max-w-full items-center gap-3 rounded-2xl border border-foreground/15 bg-foreground/10 px-4 py-3 text-foreground shadow-sm animate-in fade-in slide-in-from-bottom-1 duration-300 motion-reduce:animate-none"
data-testid="backup-test-file-accepted"
>
{error}
</p>
) : null}
</div>
<div className="flex items-center justify-center gap-3 pt-2">
<Button
className={cn(
"font-medium",
isSpotlight
? ONBOARDING_SECURITY_PRIMARY_CTA_CLASS
: "h-9 px-6 text-sm text-primary-foreground",
)}
data-testid="backup-test-verify"
disabled={!attempt || isVerifying}
onClick={() => void handleVerify()}
type="button"
>
{isVerifying ? (
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-foreground/10">
<FileKey2
aria-hidden="true"
className="size-5 text-foreground/80"
/>
</span>
<span className="max-w-70 truncate font-mono text-sm">
{fileName}
</span>
</div>
);
const passwordField = (
<div className="relative w-full">
<Input
aria-label="Backup password"
autoComplete="off"
className={cn(
"font-mono",
isSpotlight
? "h-14 rounded-2xl border-black/20 bg-white px-14 text-center text-lg text-black/80 shadow-none placeholder:text-black/55 focus-visible:ring-black/35"
: "h-10 bg-background pr-10",
)}
data-testid="backup-test-password"
disabled={isVerifying}
onChange={(event) => setAttempt(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleVerify();
}
}}
placeholder="Your backup password"
ref={passwordInputRef}
type={isRevealed ? "text" : "password"}
value={attempt}
/>
<Button
aria-label={isRevealed ? "Hide password" : "Reveal password"}
className={cn(
"absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground",
isSpotlight &&
"text-black/55 hover:bg-black/5 hover:text-black/80",
)}
data-testid="backup-test-password-reveal-toggle"
disabled={isVerifying}
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>
{error ? (
<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-error"
role="alert"
>
{error}
</p>
) : null}
</div>
);
if (!isSpotlight) {
return (
<>
<Spinner className="h-4 w-4 border-2" />
Checking…
{fileRow}
<p className="text-center text-sm leading-6 text-muted-foreground">
Enter the password to prove you can unlock this backup.
</p>
{passwordField}
</>
) : (
"Verify backup"
)}
</Button>
<Button
className={
isSpotlight
? ONBOARDING_SECONDARY_CTA_CLASS
: "h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
}
data-testid="backup-test-use-different-file"
disabled={isVerifying}
onClick={() => {
requestRef.current += 1;
setAttempt("");
setError(null);
setIsRevealed(false);
onProgressChange(initialBackupTestProgress);
}}
size="sm"
type="button"
variant="ghost"
>
Use a different file
</Button>
</div>
);
}
return (
<div className="flex w-full flex-col items-center">
<CircleHelp
aria-hidden="true"
className="size-10 text-foreground/85"
/>
<VerificationConnector
delayOffset={VERIFICATION_CONNECTOR_DOTS.length}
reduceMotion={reduceMotion}
/>
{passwordField}
<VerificationConnector
delayOffset={0}
reduceMotion={reduceMotion}
/>
{fileRow}
</div>
);
})()}
{(() => {
const verifyButton = (
<Button
className={cn(
"font-medium",
isSpotlight
? ONBOARDING_SECURITY_PRIMARY_CTA_CLASS
: "h-9 px-6 text-sm text-primary-foreground",
)}
data-testid="backup-test-verify"
disabled={!attempt || isVerifying}
onClick={() => void handleVerify()}
type="button"
>
{isVerifying ? (
<>
<Spinner className="h-4 w-4 border-2" />
Checking…
</>
) : (
"Verify backup"
)}
</Button>
);
if (verifyButtonPortal === undefined) {
return (
<div className="flex justify-center pt-2">{verifyButton}</div>
);
}
return verifyButtonPortal
? createPortal(verifyButton, verifyButtonPortal)
: null;
})()}
</motion.div>
)}
</div>

View File

@@ -1,6 +1,16 @@
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import {
ONBOARDING_SECURITY_PRIMARY_CTA_CLASS,
@@ -41,7 +51,16 @@ export function DownloadKeyStep({
// Once the encrypted payload is saved, the creator advances to its guided
// backup test while this surface keeps its own navigation.
const hasCreated = session.created;
const [createButtonSlot, setCreateButtonSlot] =
const hasSelectedBackup = session.test.stage === "password";
const hasUnsavedProgress =
!hasCreated &&
(session.state.passphrase.length > 0 ||
session.state.requestId !== null ||
session.state.encrypted !== null ||
session.state.ncryptsec !== null ||
session.state.downloadPending);
const [confirmBack, setConfirmBack] = React.useState(false);
const [primaryActionSlot, setPrimaryActionSlot] =
React.useState<HTMLElement | null>(null);
return (
@@ -61,14 +80,18 @@ 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">
{hasCreated
? "Now, test your backup"
: "Backup your key with a password"}
{hasSelectedBackup
? "That’s your backup file"
: hasCreated
? "Optionally, test your backup"
: "Backup your key with a password"}
</h1>
<p className="mt-5 text-sm leading-6 text-foreground/80">
{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."}
{hasSelectedBackup
? "Now enter your password to prove you can unlock it."
: hasCreated
? "Learn how 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>
</motion.div>
@@ -90,9 +113,10 @@ export function DownloadKeyStep({
>
<EncryptedBackupCreator
createButtonClassName={ONBOARDING_SECURITY_PRIMARY_CTA_CLASS}
createButtonPortal={createButtonSlot}
createButtonPortal={primaryActionSlot}
session={session}
variant="spotlight"
verifyButtonPortal={primaryActionSlot}
/>
</div>
</motion.div>
@@ -100,23 +124,59 @@ export function DownloadKeyStep({
</div>
<OnboardingFooter>
{hasCreated ? null : (
<div
className="flex justify-center"
data-testid="onboarding-create-slot"
ref={setCreateButtonSlot}
/>
)}
<div
className="flex justify-center"
data-testid={
hasCreated ? "onboarding-verify-slot" : "onboarding-create-slot"
}
ref={setPrimaryActionSlot}
/>
<Button
className={ONBOARDING_SECONDARY_CTA_CLASS}
data-testid="onboarding-back"
onClick={onBack}
onClick={() => {
if (hasUnsavedProgress) {
setConfirmBack(true);
return;
}
onBack();
}}
type="button"
variant="ghost"
>
Back
{hasCreated ? "Skip for now" : "Back"}
</Button>
</OnboardingFooter>
<AlertDialog open={confirmBack} onOpenChange={setConfirmBack}>
<AlertDialogContent
className="buzz-onboarding-security-theme"
data-testid="backup-discard-dialog"
surface="textured"
textureSize="compact"
textureTone="dark"
>
<AlertDialogHeader>
<AlertDialogTitle>Start over?</AlertDialogTitle>
<AlertDialogDescription>
Going back now clears any password you’ve entered and unfinished
backup progress. If you haven’t downloaded the file, you’ll need
to create it again.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className={ONBOARDING_SECONDARY_CTA_CLASS}>
Keep working
</AlertDialogCancel>
<AlertDialogAction
className={ONBOARDING_SECURITY_PRIMARY_CTA_CLASS}
data-testid="backup-discard-confirm"
onClick={onBack}
>
Discard and go back
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</OnboardingSlideTransition>
);
}

View File

@@ -322,6 +322,17 @@ export function backupSessionToPasswordEntry(
session.setTest(initialBackupTestProgress);
}
/** Discard all backup-creation and verification progress. */
export function resetEncryptedBackupSession(
session: EncryptedBackupSession,
): void {
session.dispatch({ type: "start-new-backup" });
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";
@@ -330,6 +341,8 @@ type EncryptedBackupCreatorProps = {
* rendering inline.
*/
createButtonPortal?: HTMLElement | null;
/** Optional onboarding footer target for the guided-test verification CTA. */
verifyButtonPortal?: HTMLElement | null;
/** Extra classes for the "Download" button. */
createButtonClassName?: string;
/**
@@ -534,6 +547,7 @@ function PassphraseGeneratorPopover({
export function EncryptedBackupCreator({
variant = "spotlight",
createButtonPortal,
verifyButtonPortal,
createButtonClassName,
session: sessionProp,
onCreated,
@@ -692,8 +706,8 @@ export function EncryptedBackupCreator({
onVerified={handleVerified}
progress={test}
saveError={saveError}
savedPath={savedPath}
variant={variant}
verifyButtonPortal={verifyButtonPortal}
/>
</div>
);

View File

@@ -15,6 +15,7 @@ import { DefaultConfigStep } from "./DefaultConfigStep";
import { DownloadKeyStep } from "./DownloadKeyStep";
import {
backupSessionToPasswordEntry,
resetEncryptedBackupSession,
useEncryptedBackupSession,
} from "./EncryptedBackupCreator";
import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog";
@@ -279,6 +280,7 @@ export function MachineOnboardingFlow({
<DownloadKeyStep
direction={backupDirection}
onBack={() => {
resetEncryptedBackupSession(backupSession);
setBackupDirection("backward");
setReturningFromSecurity(false);
setBackupSubview("options");
@@ -292,6 +294,7 @@ export function MachineOnboardingFlow({
onBack={() => setPage("identity")}
onNext={() => setPage("setup")}
onOpenPasswordBackup={() => {
resetEncryptedBackupSession(backupSession);
setBackupDirection("forward");
setReturningFromSecurity(false);
setBackupSubview("password");