diff --git a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx index 34222aee4b..dcb89d04d1 100644 --- a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx +++ b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx @@ -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>; @@ -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 ( +
+ {VERIFICATION_CONNECTOR_DOTS.map((dot, index) => ( + + ))} +
+ ); +} + /** * "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 ? : null} Re-download backup - {savedPath ? ( -

- Saved to {savedPath} -

- ) : null} ) : null} {saveError ? ( @@ -465,119 +511,142 @@ export function BackupTestFlow({ key="password" transition={{ duration: reduceMotion ? 0 : 0.3, ease: "easeOut" }} > -
-
-

- That's the one. Now enter your password to prove you can unlock it. -

-
- 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} - /> - - {error ? ( -

{ + const fileRow = ( +

- {error} -

- ) : null} -
-
-
+ ); + const passwordField = ( +
+ 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} + /> + + {error ? ( +

+ {error} +

+ ) : null} +
+ ); + if (!isSpotlight) { + return ( <> - - Checking… + {fileRow} +

+ Enter the password to prove you can unlock this backup. +

+ {passwordField} - ) : ( - "Verify backup" - )} - - -
+ ); + } + return ( +
+
+ ); + })()} + {(() => { + const verifyButton = ( + + ); + if (verifyButtonPortal === undefined) { + return ( +
{verifyButton}
+ ); + } + return verifyButtonPortal + ? createPortal(verifyButton, verifyButtonPortal) + : null; + })()} )} diff --git a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx index ab797b47d9..6329807521 100644 --- a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx +++ b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx @@ -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(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. */}

- {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"}

- {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."}

@@ -90,9 +113,10 @@ export function DownloadKeyStep({ > @@ -100,23 +124,59 @@ export function DownloadKeyStep({ - {hasCreated ? null : ( -
- )} +
+ + + + Start over? + + 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. + + + + + Keep working + + + Discard and go back + + + + ); } diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx index 080a8abcc4..33e3bcd41c 100644 --- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -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} />
); diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index 32c164f9e0..619e6229d8 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -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({ { + 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");