From 0d704d990452d119aeb1569725636cb27528ffe7 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 28 Jul 2026 22:38:18 -0700 Subject: [PATCH] feat(onboarding): promote encrypted key download to a "Backup your key" step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the backup step's in-place download view into its own page in the machine onboarding flow, with its own progress dot, slide transition, and footer. The chooser keeps the masked-key card; "Backup your key" (primary CTA) now navigates forward instead of swapping views, with a delayed fade-in ghost "Skip for now" beside it and a Settings hint below. - DownloadKeyStep.tsx (new): "Backup your key" page — title + encryption explainer, shell box + closing brackets animating in on mount (replaces the card→box layoutId morph, which can't span two pages), textured card hosting EncryptedBackupCreator, and a footer whose primary slot holds the portaled "Encrypt and download" button until the payload exists, then a primary "Next" that continues to setup - BackupStep.tsx: reduced to the chooser; removed view state, ShellBracket, BackToOptions, and the morph; new onDownload prop; footer reworked to a centered primary CTA with an absolutely-positioned "Skip for now" that fades in after 1s (animation-delay/duration arbitrary properties so the hover transition isn't delayed) and a Settings → Profile → Identity hint - EncryptedBackupCreator.tsx: new opt-in createButtonPortal / createButtonClassName / onCreated props so onboarding can relocate the create button into its footer (undefined = inline for Settings; null = slot not mounted yet, render nothing to avoid an inline flash) - MachineOnboardingFlow.tsx: new "download" page between backup and setup (Back → backup, Next → setup); progress indices shifted (download=3, setup=4, config=5) - OnboardingChrome.tsx: TOTAL_ONBOARDING_PAGES 7 → 8; WelcomeSetup 5→6 and CommunityOnboardingFlow 6→7/7→8 dot positions bumped to match - SetupStep.tsx: same footer treatment — centered Next with a no-background "Skip for now" hanging off its right edge, fading in after 1s - tests: passThroughBackupStep helper now skips from the chooser; onboarding-backup.spec.ts covers the new page (create button absent from footer until encryption, Back returns to the chooser); 47 e2e + 3778 unit tests pass Signed-off-by: Taylor Ho Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/communities/ui/WelcomeSetup.tsx | 2 +- .../src/features/onboarding/ui/BackupStep.tsx | 352 ++++++------------ .../onboarding/ui/CommunityOnboardingFlow.tsx | 2 +- .../onboarding/ui/DownloadKeyStep.tsx | 185 +++++++++ .../onboarding/ui/EncryptedBackupCreator.tsx | 61 ++- .../onboarding/ui/MachineOnboardingFlow.tsx | 19 +- .../onboarding/ui/OnboardingChrome.tsx | 9 +- .../src/features/onboarding/ui/SetupStep.tsx | 41 +- desktop/tests/e2e/onboarding-backup.spec.ts | 23 +- desktop/tests/helpers/onboarding.ts | 9 +- 10 files changed, 400 insertions(+), 303 deletions(-) create mode 100644 desktop/src/features/onboarding/ui/DownloadKeyStep.tsx diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index bde145822..71bd382db 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -103,7 +103,7 @@ export function WelcomeSetup({ data-system-color-scheme={systemColorScheme} > - +
{page === "welcome" ? ( diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index 54ce78626..4419b6e63 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -1,5 +1,4 @@ -import { ArrowLeft, Check, Copy, Eye, EyeOff, Info } from "lucide-react"; -import { motion, type Transition, useReducedMotion } from "motion/react"; +import { Check, Copy, Eye, EyeOff, Info } from "lucide-react"; import * as React from "react"; import { getNsec } from "@/shared/api/tauriIdentity"; @@ -16,7 +15,6 @@ import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "./OnboardingSlideTransition"; -import { EncryptedBackupCreator } from "./EncryptedBackupCreator"; import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay"; /** @@ -36,87 +34,6 @@ let introPlayed = false; const REVEAL_ANIMATION_CLASS = "animate-in fade-in duration-700 motion-reduce:animate-none"; -/** - * Shared-element id for the nsec card → shell box morph between the - * chooser and the download view. Same id on both sides lets Motion run the - * scale-down/translate-up FLIP with an automatic crossfade. - */ -const NSEC_SHELL_LAYOUT_ID = "nsec-shell-box"; - -/** Morph tween — same curve as the onboarding line-slide keyframes. */ -const SHELL_MORPH_TRANSITION: Transition = { - type: "tween", - duration: 0.55, - ease: [0.22, 1, 0.36, 1], -}; - -/** - * 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 ( - - ); -} - -/** - * The chooser offers both backup methods: copying happens in place, while the - * encrypted download flow expands into its own view. - */ -type BackupView = "choose" | "download"; - -function BackToOptions({ onClick }: { onClick: () => void }) { - return ( -
- -
- ); -} - /** Saving a Keycase is recommended, never required to continue onboarding. */ export function backupNextDisabled(): boolean { return false; @@ -125,22 +42,25 @@ export function backupNextDisabled(): boolean { type BackupStepProps = { direction: OnboardingTransitionDirection; onBack: () => void; + /** Advances to the dedicated "Download your key" onboarding step. */ + onDownload: () => void; onNext: () => void; }; /** - * Onboarding backup step — offers two ways to back up the new key without - * blocking setup: a portable password-protected Keycase (recommended) or a - * direct clipboard copy destined for a password manager. The raw key is - * fetched only when the user explicitly clicks Copy, goes straight to the - * clipboard, and is never rendered or held in state. + * 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 + * clicks Copy or Reveal, and is never held in state before that. */ -export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { +export function BackupStep({ + direction, + onBack, + onDownload, + onNext, +}: BackupStepProps) { const [created, setCreated] = React.useState(introPlayed); - const [view, setView] = React.useState("choose"); - // True once the card → shell-box morph has finished; gates the brackets. - const [shellSettled, setShellSettled] = React.useState(false); - const reduceMotion = useReducedMotion() ?? false; const [copyState, setCopyState] = React.useState< "idle" | "copying" | "copied" >("idle"); @@ -274,175 +194,118 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { REVEAL_ANIMATION_CLASS, )} > - {view === "choose" ? ( -
- - -
-
-

- {isRevealed && nsec ? nsec : maskedKey} -

-
-
+
+ +
+
+

+ {isRevealed && nsec ? nsec : maskedKey} +

+
+
+ + + - - - - - - Copy your key and save it somewhere safe — a password - manager is a great place for it. - - -
-
- {copyError ? ( -

- Could not retrieve your private key: {copyError}. You can - continue and find it later in Settings > Profile > - Identity. -

- ) : null} -
- - -

- - - Never share your private key. Anyone with this key can - impersonate you and access everything in your account. - -

-
- ) : ( -
-
- - setShellSettled(true)} - transition={ - reduceMotion ? { duration: 0 } : SHELL_MORPH_TRANSITION - } - /> - + + + Copy your key and save it somewhere safe — a password + manager is a great place for it. + + +
- - -
-
-

Download your key

-

- Your key is encrypted with your password before it - downloads. Keep the file private — you need both it and - the password to restore your identity. -

-
- -
-
- { - setShellSettled(false); - setView("choose"); - }} - /> -
-
- )} + {copyError ? ( +

+ Could not retrieve your private key: {copyError}. You can + continue and find it later in Settings > Profile > + Identity. +

+ ) : null} + + +

+ + + Never share your private key. Anyone with this key can + impersonate you and access everything in your account. + +

+
)} {created ? ( - - - {view === "choose" ? ( + {/* Relative row keeps the primary CTA truly centered while Skip + hangs off its right edge without shifting the center. */} +
+ - ) : null} +
+ +

+ You can back up your key anytime in Settings → Profile → + Identity. +

) : null} diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 4b729ab33..805d19661 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -448,7 +448,7 @@ export function CommunityOnboardingFlow({ > {isProfileStage || isTeamStage ? ( - + ) : null}
+ ); +} + +type DownloadKeyStepProps = { + direction: OnboardingTransitionDirection; + onBack: () => void; + onNext: () => void; +}; + +/** + * Onboarding download step — the password-first encrypted key download + * (Keycase) flow, promoted to its own page in the machine onboarding flow. + * The raw key never enters this component: Rust builds the NIP-49 payload + * locally and the native save dialog produces the user-owned file. + */ +export function DownloadKeyStep({ + direction, + onBack, + 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); + // Footer slot the creator portals its "Encrypt and download" button into. + const [createButtonSlot, setCreateButtonSlot] = + React.useState(null); + + return ( + +
+ {/* Plain string concat: cn()'s tailwind-merge misreads the custom + text-title size token as conflicting with text-foreground. */} +

+ Backup your key +

+

+ Your key is encrypted with your password before it downloads. Keep the + file private — you need both it and the password to restore your + identity. +

+
+ +
+
+
+ + setShellSettled(true)} + transition={ + reduceMotion + ? { duration: 0 } + : { duration: 0.45, ease: [0.22, 1, 0.36, 1] } + } + /> + +
+ + +
+ setHasCreated(true)} + variant="spotlight" + /> +
+
+
+
+
+ + + {hasCreated ? ( + + ) : ( +
+ )} + + + +

+ You can back up your key anytime in Settings → Profile → + Identity. +

+ + + ); +} diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx index 8399b70c8..d0704e6fd 100644 --- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -1,5 +1,6 @@ import { AlertTriangle, RefreshCw } from "lucide-react"; import * as React from "react"; +import { createPortal } from "react-dom"; import { createNcryptsecBackup, @@ -23,6 +24,15 @@ import { NsecMaskedDisplay } from "./NsecMaskedDisplay"; type EncryptedBackupCreatorProps = { /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ variant?: "spotlight" | "boxed"; + /** + * When set, the "Encrypt and download" button is portaled into this element + * (e.g. the onboarding footer's primary slot) instead of rendering inline. + */ + createButtonPortal?: HTMLElement | null; + /** Extra classes for the "Encrypt and download" button. */ + createButtonClassName?: string; + /** Fired once the encrypted payload has been created (before saving). */ + onCreated?: () => void; /** Fired only after the encrypted key file has been saved successfully. */ onSaved?: (path: string) => void; }; @@ -35,6 +45,9 @@ type EncryptedBackupCreatorProps = { */ export function EncryptedBackupCreator({ variant = "spotlight", + createButtonPortal, + createButtonClassName, + onCreated, onSaved, }: EncryptedBackupCreatorProps) { const [state, dispatch] = React.useReducer( @@ -79,6 +92,7 @@ export function EncryptedBackupCreator({ const ncryptsec = await createNcryptsecBackup(passphrase); if (!mountedRef.current) return; dispatch({ type: "create-succeeded", ncryptsec }); + onCreated?.(); setIsSaving(true); setSaveError(null); try { @@ -103,7 +117,7 @@ export function EncryptedBackupCreator({ err instanceof Error ? err.message : "Failed to encrypt your key.", }); } - }, [onSaved, state]); + }, [onCreated, onSaved, state]); const handleSaveCopy = React.useCallback(async () => { if (!state.ncryptsec || isSaving) return; @@ -300,24 +314,33 @@ export function EncryptedBackupCreator({

) : null} -
- -
+ {(() => { + const createButton = ( + + ); + // `undefined` = inline (settings); `null` = slot not mounted yet + // (skip a frame rather than flashing the button inline). + if (createButtonPortal === undefined) + return
{createButton}
; + return createButtonPortal + ? createPortal(createButton, createButtonPortal) + : null; + })()}
); } diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index a93d75f2e..0cdec027e 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -10,6 +10,7 @@ import { Button } from "@/shared/ui/button"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { BackupStep } from "./BackupStep"; import { DefaultConfigStep } from "./DefaultConfigStep"; +import { DownloadKeyStep } from "./DownloadKeyStep"; import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog"; import { LandingBees } from "./LandingBees"; import { NostrKeyImportForm } from "./NostrKeyImportForm"; @@ -25,6 +26,7 @@ export type MachineOnboardingPage = | "identity" | "key-import" | "backup" + | "download" | "setup" | "config"; @@ -136,7 +138,15 @@ export function MachineOnboardingFlow({ {page === "identity" ? : null} {page !== "identity" ? ( ) : null} @@ -227,6 +237,13 @@ export function MachineOnboardingFlow({ setPage("identity")} + onDownload={() => setPage("download")} + onNext={() => setPage("setup")} + /> + ) : page === "download" ? ( + setPage("backup")} onNext={() => setPage("setup")} /> ) : page === "setup" ? ( diff --git a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx index 936313bce..24f020a09 100644 --- a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx @@ -1,11 +1,12 @@ import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark"; /** - * Positions in the first-launch flow: landing, identity/key, harness setup, - * default config, community choice, community profile, meet the team. Used as - * the default pagination length when a flow doesn't pass an explicit total. + * Positions in the first-launch flow: landing, identity/key, key download, + * harness setup, default config, community choice, community profile, meet + * the team. Used as the default pagination length when a flow doesn't pass an + * explicit total. */ -export const TOTAL_ONBOARDING_PAGES = 7; +export const TOTAL_ONBOARDING_PAGES = 8; /** Shared pill shape (38px tall) for every onboarding primary CTA. */ const ONBOARDING_CTA_SHAPE = "h-[2.375rem] rounded-full px-6"; diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 843206aa7..40d62ccd3 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -685,25 +685,28 @@ function SetupStepContent({ /> - - - + {/* Relative row keeps the primary CTA truly centered while Skip + hangs off its right edge without shifting the center. */} +
+ + +