mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(onboarding): promote encrypted key download to a "Backup your key" step
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 <taylorkmho@gmail.com> Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -103,7 +103,7 @@ export function WelcomeSetup({
|
||||
data-system-color-scheme={systemColorScheme}
|
||||
>
|
||||
<StartupWindowDragRegion />
|
||||
<OnboardingChrome current={5} />
|
||||
<OnboardingChrome current={6} />
|
||||
<OnboardingFooterProvider>
|
||||
<div className="relative flex min-h-0 w-full max-w-[920px] flex-1 flex-col items-center text-center">
|
||||
{page === "welcome" ? (
|
||||
|
||||
@@ -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 (
|
||||
<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" }
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button
|
||||
className="h-8 gap-1 text-sm text-muted-foreground hover:text-accent-foreground"
|
||||
data-testid="backup-back-to-options"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
All backup options
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<BackupView>("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" ? (
|
||||
<div className="w-full">
|
||||
<motion.div
|
||||
layoutId={NSEC_SHELL_LAYOUT_ID}
|
||||
transition={
|
||||
reduceMotion ? { duration: 0 } : SHELL_MORPH_TRANSITION
|
||||
}
|
||||
>
|
||||
<Card className="px-8 py-6" variant="textured">
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[832px] items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
ONBOARDING_KEY_TEXT_CLASS,
|
||||
isRevealed && nsec
|
||||
? "select-text"
|
||||
: "select-none blur-[4px]",
|
||||
)}
|
||||
data-testid="backup-key-value"
|
||||
>
|
||||
{isRevealed && nsec ? nsec : maskedKey}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
<div className="w-full">
|
||||
<Card className="px-8 py-6" variant="textured">
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[832px] items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
ONBOARDING_KEY_TEXT_CLASS,
|
||||
isRevealed && nsec
|
||||
? "select-text"
|
||||
: "select-none blur-[4px]",
|
||||
)}
|
||||
data-testid="backup-key-value"
|
||||
>
|
||||
{isRevealed && nsec ? nsec : maskedKey}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
<Button
|
||||
aria-label={
|
||||
isRevealed ? "Hide private key" : "Reveal private key"
|
||||
}
|
||||
className="h-10 w-10 text-muted-foreground hover:text-foreground"
|
||||
data-testid="backup-key-reveal-toggle"
|
||||
onClick={() => void toggleReveal()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{isRevealed ? (
|
||||
<EyeOff className="h-6 w-6" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye className="h-6 w-6" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label={
|
||||
isRevealed ? "Hide private key" : "Reveal private key"
|
||||
}
|
||||
aria-label="Copy private key"
|
||||
className="h-10 w-10 text-muted-foreground hover:text-foreground"
|
||||
data-testid="backup-key-reveal-toggle"
|
||||
onClick={() => void toggleReveal()}
|
||||
data-testid="backup-copy-key"
|
||||
disabled={copyState === "copying"}
|
||||
onClick={() => void copyKeyToClipboard()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{isRevealed ? (
|
||||
<EyeOff className="h-6 w-6" aria-hidden="true" />
|
||||
{copyState === "copying" ? (
|
||||
<Spinner className="h-5 w-5 border-2" />
|
||||
) : copyState === "copied" ? (
|
||||
<Check
|
||||
className="h-6 w-6 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Eye className="h-6 w-6" aria-hidden="true" />
|
||||
<Copy className="h-6 w-6" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Copy private key"
|
||||
className="h-10 w-10 text-muted-foreground hover:text-foreground"
|
||||
data-testid="backup-copy-key"
|
||||
disabled={copyState === "copying"}
|
||||
onClick={() => void copyKeyToClipboard()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{copyState === "copying" ? (
|
||||
<Spinner className="h-5 w-5 border-2" />
|
||||
) : copyState === "copied" ? (
|
||||
<Check
|
||||
className="h-6 w-6 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Copy className="h-6 w-6" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[240px] text-center">
|
||||
Copy your key and save it somewhere safe — a password
|
||||
manager is a great place for it.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{copyError ? (
|
||||
<p
|
||||
className="mt-3 text-center text-sm text-destructive"
|
||||
data-testid="backup-copy-error"
|
||||
>
|
||||
Could not retrieve your private key: {copyError}. You can
|
||||
continue and find it later in Settings > Profile >
|
||||
Identity.
|
||||
</p>
|
||||
) : null}
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<p className="mx-auto mt-6 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-[var(--buzz-onboarding-backup-ink)]">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Never share your private key. Anyone with this key can
|
||||
impersonate you and access everything in your account.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<div className="mb-6 flex items-center justify-center">
|
||||
<ShellBracket
|
||||
reduceMotion={reduceMotion}
|
||||
side="left"
|
||||
visible={shellSettled || reduceMotion}
|
||||
/>
|
||||
<motion.div
|
||||
className="h-16 w-16 rounded-2xl bg-white"
|
||||
data-testid="backup-key-shell"
|
||||
layoutId={NSEC_SHELL_LAYOUT_ID}
|
||||
onLayoutAnimationComplete={() => setShellSettled(true)}
|
||||
transition={
|
||||
reduceMotion ? { duration: 0 } : SHELL_MORPH_TRANSITION
|
||||
}
|
||||
/>
|
||||
<ShellBracket
|
||||
reduceMotion={reduceMotion}
|
||||
side="right"
|
||||
visible={shellSettled || reduceMotion}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[240px] text-center">
|
||||
Copy your key and save it somewhere safe — a password
|
||||
manager is a great place for it.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 12 }}
|
||||
transition={{ delay: 0.12, duration: 0.4, ease: "easeOut" }}
|
||||
>
|
||||
<Card className="w-full px-8 py-6" variant="textured">
|
||||
<div className="mx-auto w-full max-w-[832px]">
|
||||
<div className="mb-5 space-y-2 text-center">
|
||||
<h2 className="text-lg font-medium">Download your key</h2>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<EncryptedBackupCreator variant="spotlight" />
|
||||
</div>
|
||||
</Card>
|
||||
<BackToOptions
|
||||
onClick={() => {
|
||||
setShellSettled(false);
|
||||
setView("choose");
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
{copyError ? (
|
||||
<p
|
||||
className="mt-3 text-center text-sm text-destructive"
|
||||
data-testid="backup-copy-error"
|
||||
>
|
||||
Could not retrieve your private key: {copyError}. You can
|
||||
continue and find it later in Settings > Profile >
|
||||
Identity.
|
||||
</p>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<p className="mx-auto mt-6 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-[var(--buzz-onboarding-backup-ink)]">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Never share your private key. Anyone with this key can
|
||||
impersonate you and access everything in your account.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{created ? (
|
||||
<OnboardingFooter className={REVEAL_ANIMATION_CLASS}>
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="onboarding-next"
|
||||
disabled={backupNextDisabled()}
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
{view === "choose" ? (
|
||||
{/* 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="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="backup-option-download"
|
||||
onClick={() => setView("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"
|
||||
>
|
||||
Download my key
|
||||
Skip for now
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
|
||||
@@ -453,6 +316,11 @@ export function BackupStep({ direction, onBack, onNext }: 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>
|
||||
|
||||
@@ -448,7 +448,7 @@ export function CommunityOnboardingFlow({
|
||||
>
|
||||
<StartupWindowDragRegion />
|
||||
{isProfileStage || isTeamStage ? (
|
||||
<OnboardingChrome current={isTeamStage ? 7 : 6} />
|
||||
<OnboardingChrome current={isTeamStage ? 8 : 7} />
|
||||
) : null}
|
||||
<OnboardingFooterProvider>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
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";
|
||||
import { OnboardingFooter } from "./OnboardingFooter";
|
||||
import {
|
||||
type OnboardingTransitionDirection,
|
||||
OnboardingSlideTransition,
|
||||
} 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;
|
||||
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<HTMLElement | null>(null);
|
||||
|
||||
return (
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-0 w-full flex-col items-center"
|
||||
data-testid="onboarding-page-download"
|
||||
direction={direction}
|
||||
transitionKey={`download-${direction}`}
|
||||
>
|
||||
<div className="flex w-full max-w-[500px] shrink-0 flex-col text-center">
|
||||
{/* 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
|
||||
</h1>
|
||||
<p className="mt-5 text-sm leading-6 text-foreground/80">
|
||||
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.
|
||||
</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 }}
|
||||
transition={{ delay: 0.12, duration: 0.4, ease: "easeOut" }}
|
||||
>
|
||||
<Card className="w-full px-8 py-6" variant="textured">
|
||||
<div className="mx-auto w-full max-w-[832px]">
|
||||
<EncryptedBackupCreator
|
||||
createButtonClassName={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
createButtonPortal={createButtonSlot}
|
||||
onCreated={() => setHasCreated(true)}
|
||||
variant="spotlight"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnboardingFooter>
|
||||
{hasCreated ? (
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="onboarding-next"
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<div
|
||||
className="flex justify-center"
|
||||
data-testid="onboarding-create-slot"
|
||||
ref={setCreateButtonSlot}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
|
||||
data-testid="onboarding-back"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-foreground/50">
|
||||
You can back up your key anytime in Settings → Profile →
|
||||
Identity.
|
||||
</p>
|
||||
</OnboardingFooter>
|
||||
</OnboardingSlideTransition>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
className="h-9 rounded-full px-6"
|
||||
data-testid="encrypted-backup-create"
|
||||
disabled={createDisabled(state)}
|
||||
onClick={() => void handleCreate()}
|
||||
type="button"
|
||||
>
|
||||
{state.isCreating ? (
|
||||
<>
|
||||
<Spinner className="h-4 w-4 border-2" />
|
||||
Encrypting… this takes a couple of seconds
|
||||
</>
|
||||
) : (
|
||||
"Encrypt and download"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{(() => {
|
||||
const createButton = (
|
||||
<Button
|
||||
className={cn("h-9 rounded-full px-6", createButtonClassName)}
|
||||
data-testid="encrypted-backup-create"
|
||||
disabled={createDisabled(state)}
|
||||
onClick={() => void handleCreate()}
|
||||
type="button"
|
||||
>
|
||||
{state.isCreating ? (
|
||||
<>
|
||||
<Spinner className="h-4 w-4 border-2" />
|
||||
Encrypting… this takes a couple of seconds
|
||||
</>
|
||||
) : (
|
||||
"Encrypt and download"
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
// `undefined` = inline (settings); `null` = slot not mounted yet
|
||||
// (skip a frame rather than flashing the button inline).
|
||||
if (createButtonPortal === undefined)
|
||||
return <div className="flex justify-center">{createButton}</div>;
|
||||
return createButtonPortal
|
||||
? createPortal(createButton, createButtonPortal)
|
||||
: null;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" ? <LandingBees /> : null}
|
||||
{page !== "identity" ? (
|
||||
<OnboardingChrome
|
||||
current={page === "config" ? 4 : page === "setup" ? 3 : 2}
|
||||
current={
|
||||
page === "config"
|
||||
? 5
|
||||
: page === "setup"
|
||||
? 4
|
||||
: page === "download"
|
||||
? 3
|
||||
: 2
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<OnboardingFooterProvider>
|
||||
@@ -227,6 +237,13 @@ export function MachineOnboardingFlow({
|
||||
<BackupStep
|
||||
direction="forward"
|
||||
onBack={() => setPage("identity")}
|
||||
onDownload={() => setPage("download")}
|
||||
onNext={() => setPage("setup")}
|
||||
/>
|
||||
) : page === "download" ? (
|
||||
<DownloadKeyStep
|
||||
direction="forward"
|
||||
onBack={() => setPage("backup")}
|
||||
onNext={() => setPage("setup")}
|
||||
/>
|
||||
) : page === "setup" ? (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -685,25 +685,28 @@ function SetupStepContent({
|
||||
/>
|
||||
|
||||
<OnboardingFooter>
|
||||
<Button
|
||||
className={`${ONBOARDING_PRIMARY_CTA_CLASS} text-sm`}
|
||||
data-testid="onboarding-setup-next"
|
||||
disabled={readyRuntimeIds.length === 0}
|
||||
onClick={() => actions.next(readyRuntimeIds)}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 text-sm hover:bg-foreground/15"
|
||||
data-testid="onboarding-setup-skip"
|
||||
onClick={() => actions.next([])}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
{/* 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} text-sm`}
|
||||
data-testid="onboarding-setup-next"
|
||||
disabled={readyRuntimeIds.length === 0}
|
||||
onClick={() => actions.next(readyRuntimeIds)}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
className="absolute left-full ml-3 h-9 animate-in whitespace-nowrap rounded-full px-6 text-sm fade-in fill-mode-backwards [animation-delay:1000ms] animation-duration-[500ms] hover:bg-foreground/10 motion-reduce:animate-none"
|
||||
data-testid="onboarding-setup-skip"
|
||||
onClick={() => actions.next([])}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 text-sm hover:bg-foreground/15"
|
||||
|
||||
@@ -89,8 +89,9 @@ test("chooser shows masked key; reveal and copy fetch it explicitly", async ({
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encrypted download path: password → encrypt locally → native save → saved
|
||||
// confirmation. The raw key must never be fetched on this path.
|
||||
// Encrypted download path ("Backup your key" step): password → encrypt
|
||||
// locally → native save → saved confirmation. The raw key must never be
|
||||
// fetched on this path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("download happy path: generated password, encrypt, native save, Next", async ({
|
||||
@@ -98,13 +99,15 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
|
||||
}) => {
|
||||
await enterMachineBackup(page);
|
||||
|
||||
// The download flow sits behind the footer CTA.
|
||||
// The download flow is its own onboarding step behind the footer CTA.
|
||||
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
|
||||
await page.getByTestId("backup-option-download").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
|
||||
// Default mode: generated password shown, but backup remains optional.
|
||||
// Default mode: generated password shown; the create button sits in the
|
||||
// footer's primary slot until the encrypted payload exists.
|
||||
await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
|
||||
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/03-backup-download-passphrase.png` });
|
||||
@@ -135,17 +138,17 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
|
||||
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
|
||||
});
|
||||
|
||||
test("download view returns to the chooser via All backup options", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("download step Back returns to the backup chooser", async ({ page }) => {
|
||||
await enterMachineBackup(page);
|
||||
|
||||
await page.getByTestId("backup-option-download").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible();
|
||||
// The footer download CTA hides while the flow is open.
|
||||
// The chooser's footer CTA belongs to the previous step.
|
||||
await expect(page.getByTestId("backup-option-download")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("backup-back-to-options").click();
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -16,13 +16,10 @@ export async function seedActiveIdentity(
|
||||
);
|
||||
}
|
||||
|
||||
/** Navigate through the backup step (fresh-key path, encrypted default). */
|
||||
/** Navigate through the backup step (fresh-key path). */
|
||||
export async function passThroughBackupStep(page: Page) {
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
// Encrypted-by-default: create the backup with the generated passphrase,
|
||||
// then advance. (The raw key path is behind "Show raw key instead".)
|
||||
await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible();
|
||||
await page.getByTestId("encrypted-backup-create").click();
|
||||
await expect(page.getByTestId("ncryptsec-value")).toBeVisible();
|
||||
// Backing up is recommended, never required — "Skip for now" advances
|
||||
// straight to setup without visiting the "Backup your key" step.
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user