mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): integrate backup options into identity onboarding
- Replace the standalone password-backup step with copy and encrypted-file options inside the identity-key stage. - Add a midnight security theme, return-to-onboarding control, and downward transition back into the core yellow flow. - Restore seven-page onboarding pagination and keep backup progress available when users revisit the security view. - Update onboarding E2E coverage and screenshots for the inline options, direct setup path, and password-backup return behavior. 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={6} />
|
||||
<OnboardingChrome current={5} />
|
||||
<OnboardingFooterProvider>
|
||||
<div className="relative flex min-h-0 w-full max-w-[920px] flex-1 flex-col items-center text-center">
|
||||
{page === "welcome" ? (
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Check, Copy, Eye, EyeOff, Info } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Info,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { getNsec } from "@/shared/api/tauriIdentity";
|
||||
@@ -8,7 +16,6 @@ import { Button } from "@/shared/ui/button";
|
||||
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
|
||||
import { OnboardingFooter } from "./OnboardingFooter";
|
||||
import {
|
||||
@@ -42,18 +49,28 @@ export function backupNextDisabled(): boolean {
|
||||
type BackupStepProps = {
|
||||
direction: OnboardingTransitionDirection;
|
||||
onBack: () => void;
|
||||
/** Advances to the dedicated "Download your key" onboarding step. */
|
||||
onDownload: () => void;
|
||||
onNext: () => void;
|
||||
onOpenPasswordBackup: () => void;
|
||||
onShowOptions: () => void;
|
||||
optionsExpanded: boolean;
|
||||
returningFromSecurity: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Onboarding identity-key step — shows the freshly created key, then opens a
|
||||
* dark backup-options state. Copy fetches the raw key only after an explicit
|
||||
* click; password backup opens the separate security flow. Neither method
|
||||
* blocks Next.
|
||||
*/
|
||||
export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) {
|
||||
export function BackupStep({
|
||||
direction,
|
||||
onBack,
|
||||
onNext,
|
||||
onOpenPasswordBackup,
|
||||
onShowOptions,
|
||||
optionsExpanded,
|
||||
returningFromSecurity,
|
||||
}: BackupStepProps) {
|
||||
const [created, setCreated] = React.useState(introPlayed);
|
||||
const [copyState, setCopyState] = React.useState<
|
||||
"idle" | "copying" | "copied"
|
||||
@@ -136,12 +153,116 @@ export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) {
|
||||
[nsec],
|
||||
);
|
||||
|
||||
if (optionsExpanded) {
|
||||
return (
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-0 w-full flex-col items-center"
|
||||
data-testid="onboarding-page-backup-options"
|
||||
direction={direction}
|
||||
effect={direction === "forward" ? "mask-reveal-up" : "line-slide"}
|
||||
transitionKey={`backup-options-${direction}`}
|
||||
>
|
||||
<div className="flex w-full max-w-140 shrink-0 flex-col text-center">
|
||||
<h1 className="text-title font-normal text-foreground">
|
||||
Backup options
|
||||
</h1>
|
||||
<p className="mt-5 text-sm leading-6 text-foreground/75">
|
||||
Choose how you want to keep a restorable copy of your identity key.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-240 flex-1 flex-col justify-center py-10">
|
||||
<div
|
||||
className="grid w-full grid-cols-1 gap-8 md:grid-cols-2"
|
||||
data-testid="backup-options"
|
||||
>
|
||||
<div className="relative min-h-56 w-full">
|
||||
<Card
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 brightness-[0.005]"
|
||||
variant="textured"
|
||||
/>
|
||||
<div className="relative z-10 flex min-h-56 w-full flex-col justify-center p-20 text-left text-foreground">
|
||||
<span className="text-lg font-medium">
|
||||
Save your key safely
|
||||
</span>
|
||||
<span className="mt-3 block text-sm leading-6 text-foreground/65">
|
||||
A password manager such as 1Password is a good place to keep
|
||||
your private key.
|
||||
</span>
|
||||
<Button
|
||||
className="mt-5 h-9 w-fit gap-2 rounded-full border border-white/20 bg-white/10 px-5 text-foreground hover:bg-white/15 hover:text-foreground"
|
||||
data-testid="backup-copy-key"
|
||||
disabled={copyState === "copying"}
|
||||
onClick={() => void copyKeyToClipboard()}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{copyState === "copying" ? (
|
||||
<Spinner className="h-4 w-4 border-2" />
|
||||
) : copyState === "copied" ? (
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{copyState === "copying"
|
||||
? "Copying…"
|
||||
: copyState === "copied"
|
||||
? "Copied to clipboard"
|
||||
: "Copy to clipboard"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-56 w-full">
|
||||
<Card
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 brightness-[0.005]"
|
||||
variant="textured"
|
||||
/>
|
||||
<div className="relative z-10 flex min-h-56 w-full flex-col justify-center p-20 text-left text-foreground">
|
||||
<span className="text-lg font-medium">
|
||||
Create a portable backup
|
||||
</span>
|
||||
<span className="mt-3 block text-sm leading-6 text-foreground/65">
|
||||
Create a password-protected file you can store safely and use
|
||||
to restore your identity.
|
||||
</span>
|
||||
<Button
|
||||
className="mt-5 h-9 w-fit gap-2 rounded-full border border-white/20 bg-white/10 px-5 text-foreground hover:bg-white/15 hover:text-foreground"
|
||||
data-testid="backup-option-password"
|
||||
onClick={onOpenPasswordBackup}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ShieldCheck className="h-5 w-5" aria-hidden="true" />
|
||||
Back up with a password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{copyError ? (
|
||||
<p
|
||||
className="mt-4 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}
|
||||
</div>
|
||||
</OnboardingSlideTransition>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-0 w-full flex-col items-center"
|
||||
data-testid="onboarding-page-backup"
|
||||
direction={direction}
|
||||
transitionKey={`backup-${direction}`}
|
||||
effect={returningFromSecurity ? "mask-reveal-down" : "line-slide"}
|
||||
transitionKey={`backup-${direction}-${returningFromSecurity ? "down" : "line"}`}
|
||||
>
|
||||
<div className="flex w-full max-w-[500px] shrink-0 flex-col text-center">
|
||||
{/* Plain string concat: cn()'s tailwind-merge misreads the custom
|
||||
@@ -204,55 +325,37 @@ export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) {
|
||||
{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="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>
|
||||
<Button
|
||||
aria-label={
|
||||
isRevealed ? "Hide private key" : "Reveal private key"
|
||||
}
|
||||
className="h-10 w-10 shrink-0 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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mt-4 flex flex-col items-center">
|
||||
<Button
|
||||
className="h-9 gap-1.5 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
|
||||
data-testid="backup-options-toggle"
|
||||
onClick={onShowOptions}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Backup options
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
{copyError ? (
|
||||
<p
|
||||
className="mt-3 text-center text-sm text-destructive"
|
||||
@@ -263,9 +366,9 @@ export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) {
|
||||
Identity.
|
||||
</p>
|
||||
) : null}
|
||||
</Card>
|
||||
</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)]">
|
||||
<p className="mx-auto mt-5 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
|
||||
@@ -282,7 +385,7 @@ export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) {
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="onboarding-next"
|
||||
disabled={backupNextDisabled()}
|
||||
onClick={onDownload}
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
|
||||
@@ -448,7 +448,7 @@ export function CommunityOnboardingFlow({
|
||||
>
|
||||
<StartupWindowDragRegion />
|
||||
{isProfileStage || isTeamStage ? (
|
||||
<OnboardingChrome current={isTeamStage ? 8 : 7} />
|
||||
<OnboardingChrome current={isTeamStage ? 7 : 6} />
|
||||
) : null}
|
||||
<OnboardingFooterProvider>
|
||||
<div
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from "./OnboardingSlideTransition";
|
||||
import {
|
||||
type EncryptedBackupSession,
|
||||
backupSessionToPasswordEntry,
|
||||
EncryptedBackupCreator,
|
||||
} from "./EncryptedBackupCreator";
|
||||
|
||||
@@ -23,20 +22,17 @@ type DownloadKeyStepProps = {
|
||||
* the backup-test progress.
|
||||
*/
|
||||
session: EncryptedBackupSession;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Onboarding download step — the password-first encrypted key download
|
||||
* flow, promoted to its own page in the machine onboarding flow.
|
||||
* Password-backup security subview within the identity-key onboarding step.
|
||||
* 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,
|
||||
session,
|
||||
onBack,
|
||||
onNext,
|
||||
}: DownloadKeyStepProps) {
|
||||
const reduceMotion = useReducedMotion() ?? false;
|
||||
@@ -79,7 +75,7 @@ export function DownloadKeyStep({
|
||||
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">
|
||||
<Card className="w-full border-foreground/15 bg-card px-8 py-6 shadow-none">
|
||||
<div className="mx-auto w-full max-w-[832px]">
|
||||
<EncryptedBackupCreator
|
||||
createButtonClassName={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
@@ -138,20 +134,6 @@ export function DownloadKeyStep({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
|
||||
data-testid="onboarding-back"
|
||||
onClick={
|
||||
// From the test view, Back first returns to the password form;
|
||||
// only from the form does it leave the step entirely.
|
||||
hasCreated ? () => backupSessionToPasswordEntry(session) : onBack
|
||||
}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{hasCreated ? null : (
|
||||
<p className="text-xs text-foreground/50">
|
||||
You can back up your key anytime in Settings → Profile →
|
||||
|
||||
@@ -255,10 +255,12 @@ function PassphraseGeneratorPopover({
|
||||
disabled = false,
|
||||
onRequestGenerate,
|
||||
onGenerated,
|
||||
securityTheme = false,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onRequestGenerate?: () => void;
|
||||
onGenerated: (value: string) => void;
|
||||
securityTheme?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [words, setWords] = React.useState(DEFAULT_GENERATED_WORDS);
|
||||
@@ -335,7 +337,10 @@ function PassphraseGeneratorPopover({
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-72 space-y-3"
|
||||
className={cn(
|
||||
"w-72 space-y-3",
|
||||
securityTheme && "buzz-onboarding-security-theme",
|
||||
)}
|
||||
onInteractOutside={(event) => {
|
||||
// Clicking the anchor icon is "outside" the content — keep the
|
||||
// popover open so that click re-rolls instead of closing.
|
||||
@@ -677,6 +682,7 @@ export function EncryptedBackupCreator({
|
||||
// A generated password must be visible so the user can save it.
|
||||
setIsRevealed(true);
|
||||
}}
|
||||
securityTheme={variant === "spotlight"}
|
||||
/>
|
||||
{issue ? (
|
||||
<p
|
||||
@@ -769,7 +775,12 @@ export function EncryptedBackupCreator({
|
||||
open={confirmNewPassword}
|
||||
onOpenChange={setConfirmNewPassword}
|
||||
>
|
||||
<AlertDialogContent data-testid="backup-change-password-dialog">
|
||||
<AlertDialogContent
|
||||
className={cn(
|
||||
variant === "spotlight" && "buzz-onboarding-security-theme",
|
||||
)}
|
||||
data-testid="backup-change-password-dialog"
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Create a new backup password?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
|
||||
import {
|
||||
getIdentity,
|
||||
@@ -30,10 +31,11 @@ export type MachineOnboardingPage =
|
||||
| "identity"
|
||||
| "key-import"
|
||||
| "backup"
|
||||
| "download"
|
||||
| "setup"
|
||||
| "config";
|
||||
|
||||
type BackupSubview = "created" | "options" | "password";
|
||||
|
||||
/** A pending navigation the parent should execute after RouterProvider mounts. */
|
||||
export type PostOnboardingNavigation = {
|
||||
to: string;
|
||||
@@ -71,9 +73,17 @@ export function MachineOnboardingFlow({
|
||||
null,
|
||||
);
|
||||
const [readyRuntimeIds, setReadyRuntimeIds] = React.useState<string[]>([]);
|
||||
// Owned here (not by DownloadKeyStep) so Back navigation — which unmounts
|
||||
// the step — keeps the created backup, entered password, and test progress.
|
||||
const [backupSubview, setBackupSubview] =
|
||||
React.useState<BackupSubview>("created");
|
||||
const [backupDirection, setBackupDirection] = React.useState<
|
||||
"forward" | "backward"
|
||||
>("forward");
|
||||
const [returningFromSecurity, setReturningFromSecurity] =
|
||||
React.useState(false);
|
||||
// Owned here so switching between the yellow onboarding view and the dark
|
||||
// security subview keeps the created backup, password, and test progress.
|
||||
const backupSession = useEncryptedBackupSession();
|
||||
const isSecuritySubview = page === "backup" && backupSubview !== "created";
|
||||
const handleReadyRuntimeIdsChange = React.useCallback(
|
||||
(runtimeIds: readonly string[]) => {
|
||||
setReadyRuntimeIds(Array.from(new Set(runtimeIds)));
|
||||
@@ -88,6 +98,9 @@ export function MachineOnboardingFlow({
|
||||
const identity = await getIdentity();
|
||||
queryClient.setQueryData(["identity"], identity);
|
||||
setSelectedPubkey(identity.pubkey);
|
||||
setBackupDirection("forward");
|
||||
setReturningFromSecurity(false);
|
||||
setBackupSubview("created");
|
||||
setPage("backup");
|
||||
} catch (cause) {
|
||||
setError(
|
||||
@@ -110,6 +123,9 @@ export function MachineOnboardingFlow({
|
||||
const identity = await persistCurrentIdentity();
|
||||
queryClient.setQueryData(["identity"], identity);
|
||||
setSelectedPubkey(identity.pubkey);
|
||||
setBackupDirection("forward");
|
||||
setReturningFromSecurity(false);
|
||||
setBackupSubview("created");
|
||||
setPage("backup");
|
||||
} catch (cause) {
|
||||
setError(
|
||||
@@ -135,6 +151,8 @@ export function MachineOnboardingFlow({
|
||||
return (
|
||||
<div
|
||||
className={`buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-x-hidden overflow-y-auto px-4 text-foreground ${
|
||||
isSecuritySubview ? "buzz-onboarding-security-theme" : ""
|
||||
} ${
|
||||
page === "identity"
|
||||
? "buzz-onboarding-welcome py-8"
|
||||
: "pb-28 pt-[106px]"
|
||||
@@ -143,17 +161,26 @@ export function MachineOnboardingFlow({
|
||||
>
|
||||
<StartupWindowDragRegion />
|
||||
{page === "identity" ? <LandingBees /> : null}
|
||||
{page !== "identity" ? (
|
||||
{isSecuritySubview ? (
|
||||
<div className="fixed inset-x-0 top-8 z-20 flex justify-center px-6">
|
||||
<Button
|
||||
className="h-9 gap-2 rounded-full bg-foreground/10 px-5 text-foreground hover:bg-foreground/15 hover:text-foreground"
|
||||
data-testid="backup-return-to-onboarding"
|
||||
onClick={() => {
|
||||
setBackupDirection("backward");
|
||||
setReturningFromSecurity(true);
|
||||
setBackupSubview("created");
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" aria-hidden="true" />
|
||||
Return to onboarding
|
||||
</Button>
|
||||
</div>
|
||||
) : page !== "identity" ? (
|
||||
<OnboardingChrome
|
||||
current={
|
||||
page === "config"
|
||||
? 5
|
||||
: page === "setup"
|
||||
? 4
|
||||
: page === "download"
|
||||
? 3
|
||||
: 2
|
||||
}
|
||||
current={page === "config" ? 4 : page === "setup" ? 3 : 2}
|
||||
/>
|
||||
) : null}
|
||||
<OnboardingFooterProvider>
|
||||
@@ -241,31 +268,47 @@ export function MachineOnboardingFlow({
|
||||
</div>
|
||||
</OnboardingSlideTransition>
|
||||
) : page === "backup" ? (
|
||||
<BackupStep
|
||||
direction="forward"
|
||||
onBack={() => setPage("identity")}
|
||||
onDownload={() => setPage("download")}
|
||||
/>
|
||||
) : page === "download" ? (
|
||||
<DownloadKeyStep
|
||||
direction="forward"
|
||||
onBack={() => setPage("backup")}
|
||||
onNext={() => setPage("setup")}
|
||||
session={backupSession}
|
||||
/>
|
||||
backupSubview === "password" ? (
|
||||
<DownloadKeyStep
|
||||
direction={backupDirection}
|
||||
onNext={() => setPage("setup")}
|
||||
session={backupSession}
|
||||
/>
|
||||
) : (
|
||||
<BackupStep
|
||||
direction={backupDirection}
|
||||
onBack={() => setPage("identity")}
|
||||
onNext={() => setPage("setup")}
|
||||
onOpenPasswordBackup={() => {
|
||||
setBackupDirection("forward");
|
||||
setReturningFromSecurity(false);
|
||||
setBackupSubview("password");
|
||||
}}
|
||||
onShowOptions={() => {
|
||||
setBackupDirection("forward");
|
||||
setReturningFromSecurity(false);
|
||||
setBackupSubview("options");
|
||||
}}
|
||||
optionsExpanded={backupSubview === "options"}
|
||||
returningFromSecurity={returningFromSecurity}
|
||||
/>
|
||||
)
|
||||
) : page === "setup" ? (
|
||||
<SetupStep
|
||||
actions={{
|
||||
// Fresh-key users return to the "Backup your key with a
|
||||
// password" form (not the test flow they may have finished);
|
||||
// imported keys skip that step entirely.
|
||||
// Fresh-key users return to whichever identity backup subview
|
||||
// they used to reach setup; imported keys skip backup entirely.
|
||||
back: () => {
|
||||
if (identityWasImported) {
|
||||
setPage("key-import");
|
||||
return;
|
||||
}
|
||||
backupSessionToPasswordEntry(backupSession);
|
||||
setPage("download");
|
||||
if (backupSubview === "password") {
|
||||
backupSessionToPasswordEntry(backupSession);
|
||||
}
|
||||
setBackupDirection("backward");
|
||||
setReturningFromSecurity(false);
|
||||
setPage("backup");
|
||||
},
|
||||
next: (runtimeIds) => {
|
||||
const ids = Array.from(runtimeIds);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Positions in the first-launch flow: landing, identity/key, harness setup,
|
||||
* default config, community choice, community profile, meet the team. Password
|
||||
* backup is an optional subview of identity/key, not another position.
|
||||
*/
|
||||
export const TOTAL_ONBOARDING_PAGES = 8;
|
||||
export const TOTAL_ONBOARDING_PAGES = 7;
|
||||
|
||||
/** Shared pill shape (38px tall) for every onboarding primary CTA. */
|
||||
const ONBOARDING_CTA_SHAPE = "h-[2.375rem] rounded-full px-6";
|
||||
|
||||
@@ -14,6 +14,7 @@ export type OnboardingTransitionDirection = "forward" | "backward";
|
||||
export type OnboardingTransitionEffect =
|
||||
| "fade"
|
||||
| "line-slide"
|
||||
| "mask-reveal-down"
|
||||
| "mask-reveal-up"
|
||||
| "none";
|
||||
|
||||
|
||||
@@ -241,6 +241,54 @@
|
||||
--buzz-onboarding-avatar-dialog-shadow: 0 0 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Backup options and password backup intentionally leave the bright
|
||||
* onboarding world for a dark security-focused subview. Keep these semantic
|
||||
* overrides on a reusable class as well as the shell so portaled
|
||||
* popovers/dialogs can opt into the same treatment.
|
||||
*/
|
||||
.buzz-onboarding-security-theme {
|
||||
color-scheme: dark;
|
||||
--buzz-onboarding-shell-bottom: #082b49;
|
||||
--buzz-onboarding-cta-label: #f5f5f5;
|
||||
--background: 222 45% 4%;
|
||||
--foreground: 0 0% 96%;
|
||||
--card: 220 14% 11%;
|
||||
--card-foreground: 0 0% 96%;
|
||||
--popover: 220 14% 9%;
|
||||
--popover-foreground: 0 0% 96%;
|
||||
--primary: 0 0% 16%;
|
||||
--primary-foreground: 0 0% 96%;
|
||||
--secondary: 0 0% 12%;
|
||||
--secondary-foreground: 0 0% 96%;
|
||||
--muted: 0 0% 12%;
|
||||
--muted-foreground: 0 0% 72%;
|
||||
--accent: 0 0% 16%;
|
||||
--accent-foreground: 0 0% 96%;
|
||||
--destructive: 0 84% 70%;
|
||||
--destructive-foreground: 0 0% 4%;
|
||||
--border: 0 0% 22%;
|
||||
--input: 0 0% 22%;
|
||||
--ring: 0 0% 84%;
|
||||
}
|
||||
|
||||
.buzz-onboarding-neutral-theme.buzz-startup-shell.buzz-onboarding-security-theme {
|
||||
background-color: #010103;
|
||||
background-image:
|
||||
radial-gradient(circle, rgb(143 211 255 / 0.11) 1px, transparent 1px),
|
||||
radial-gradient(
|
||||
ellipse at 50% 58%,
|
||||
rgb(28 112 196 / 0.24) 0%,
|
||||
rgb(18 76 138 / 0.1) 38%,
|
||||
transparent 66%
|
||||
),
|
||||
linear-gradient(to bottom, #010103 0%, #040914 46%, #082b49 100%);
|
||||
background-size:
|
||||
24px 24px,
|
||||
auto,
|
||||
auto;
|
||||
}
|
||||
|
||||
.buzz-onboarding-key-text {
|
||||
@apply w-full break-all [overflow-wrap:anywhere] font-mono text-nsec-key;
|
||||
|
||||
@@ -376,10 +424,13 @@
|
||||
animation-name: buzz-onboarding-line-slide-backward;
|
||||
}
|
||||
|
||||
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-down"],
|
||||
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-down"]
|
||||
> .buzz-onboarding-transition-content,
|
||||
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"]
|
||||
> .buzz-onboarding-transition-content {
|
||||
/* `backwards` (not `both`): the reveal ends on an identity transform, so
|
||||
@@ -387,12 +438,23 @@
|
||||
that establishes a containing block and traps `position: fixed`
|
||||
descendants (the bottom-docked onboarding footer). Reverting to no
|
||||
transform at rest looks identical and frees fixed positioning. */
|
||||
animation: buzz-onboarding-mask-reveal-up 760ms
|
||||
cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||
animation-duration: 760ms;
|
||||
animation-fill-mode: backwards;
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation-delay: var(--buzz-onboarding-transition-delay, 0ms);
|
||||
transform-origin: 50% 70%;
|
||||
}
|
||||
|
||||
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-down"]
|
||||
> .buzz-onboarding-transition-content {
|
||||
animation-name: buzz-onboarding-mask-reveal-down;
|
||||
}
|
||||
|
||||
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"]
|
||||
> .buzz-onboarding-transition-content {
|
||||
animation-name: buzz-onboarding-mask-reveal-up;
|
||||
}
|
||||
|
||||
.buzz-onboarding-name-placeholder-caret {
|
||||
animation: buzz-onboarding-caret-blink 1.1s steps(1, end) infinite;
|
||||
}
|
||||
@@ -543,6 +605,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes buzz-onboarding-mask-reveal-down {
|
||||
from {
|
||||
filter: blur(8px);
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -30px, 0) scale(0.985);
|
||||
}
|
||||
|
||||
56% {
|
||||
filter: blur(2px);
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
to {
|
||||
filter: blur(0);
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.buzz-onboarding-runtime-check,
|
||||
.buzz-onboarding-runtime-checkmark {
|
||||
|
||||
@@ -16,6 +16,20 @@ async function enterMachineBackup(page: import("@playwright/test").Page) {
|
||||
await page.getByRole("button", { name: "Create a new identity key" }).click();
|
||||
}
|
||||
|
||||
async function openBackupOptions(page: import("@playwright/test").Page) {
|
||||
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
|
||||
await page.getByTestId("backup-options-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("onboarding-page-backup-options"),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function openPasswordBackup(page: import("@playwright/test").Page) {
|
||||
await openBackupOptions(page);
|
||||
await page.getByTestId("backup-option-password").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
}
|
||||
|
||||
async function invokedCommands(page: import("@playwright/test").Page) {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
@@ -55,11 +69,11 @@ test("backup step appears on fresh-key path after profile submit", async ({
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chooser: masked key with reveal toggle and inline copy. The raw key is
|
||||
// fetched only on explicit reveal/copy, and Next is never blocked.
|
||||
// Key-created view: masked key with reveal toggle. Backup options open the
|
||||
// dark security view; the raw key is fetched only on explicit reveal/copy.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("chooser shows masked key; reveal and copy fetch it explicitly", async ({
|
||||
test("key view reveals explicitly; options copy explicitly", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
@@ -86,18 +100,24 @@ test("chooser shows masked key; reveal and copy fetch it explicitly", async ({
|
||||
await page.getByTestId("backup-key-reveal-toggle").click();
|
||||
await expect(key).not.toContainText("nsec1");
|
||||
|
||||
// Inline copy goes straight to the clipboard.
|
||||
// Copy is available only after opening the dark backup-options view.
|
||||
await page.getByTestId("backup-options-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("onboarding-page-backup-options"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
|
||||
await page.getByTestId("backup-copy-key").click();
|
||||
await expect
|
||||
.poll(async () => invokedCommands(page))
|
||||
.toContain("copy_text_to_clipboard");
|
||||
expect(await invokedCommands(page)).toContain("get_nsec");
|
||||
|
||||
// Next leads into the download step, where backup stays skippable.
|
||||
// Return restores the yellow key-created view; its Next skips backup and
|
||||
// continues directly to setup.
|
||||
await page.getByTestId("backup-return-to-onboarding").click();
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -112,10 +132,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's Next.
|
||||
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
// Password backup opens from the dark options state without adding an
|
||||
// onboarding progress step.
|
||||
await openPasswordBackup(page);
|
||||
|
||||
// The password field starts empty; the create button sits in the footer's
|
||||
// primary slot and stays disabled until a valid password exists.
|
||||
@@ -239,28 +258,27 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
|
||||
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
|
||||
});
|
||||
|
||||
test("download step Back returns to the backup chooser", async ({ page }) => {
|
||||
test("security view returns to the yellow onboarding view", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enterMachineBackup(page);
|
||||
await openPasswordBackup(page);
|
||||
|
||||
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 Next belongs to the previous step; the download
|
||||
// step only mounts its own Next once the backup exists.
|
||||
await expect(page.getByTestId("onboarding-step-dots")).toHaveCount(0);
|
||||
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("onboarding-back").click();
|
||||
await page.getByTestId("backup-return-to-onboarding").click();
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
await expect(page.getByTestId("backup-key-value")).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-next")).toBeVisible();
|
||||
});
|
||||
|
||||
test("test-view Back returns to a secure saved-password placeholder", async ({
|
||||
test("returning to onboarding preserves password-backup progress", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enterMachineBackup(page);
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
await openPasswordBackup(page);
|
||||
|
||||
const input = page.getByTestId("backup-passphrase-input");
|
||||
await input.fill("mock-horse-battery-staple");
|
||||
@@ -269,30 +287,20 @@ test("test-view Back returns to a secure saved-password placeholder", async ({
|
||||
page.getByRole("heading", { name: "Now, test your backup" }),
|
||||
).toBeVisible();
|
||||
|
||||
// Back retains only the encrypted blob and renders a fixed visual mask over
|
||||
// an empty readonly input.
|
||||
await page.getByTestId("onboarding-back").click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Backup your key with a password" }),
|
||||
).toBeVisible();
|
||||
await expect(input).toHaveValue("");
|
||||
await expect(input).toHaveAttribute("readonly", "");
|
||||
await expect(page.getByTestId("backup-saved-password-mask")).toBeVisible();
|
||||
await page.getByTestId("backup-return-to-onboarding").click();
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
|
||||
// Re-downloading needs no password or re-encryption.
|
||||
await page.getByTestId("encrypted-backup-create").click();
|
||||
// Re-entering the security flow restores the in-progress backup test.
|
||||
await openPasswordBackup(page);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Now, test your backup" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId("onboarding-back").click();
|
||||
await page.getByTestId("onboarding-back").click();
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
await expect(page.getByTestId("backup-test-dropzone")).toBeVisible();
|
||||
});
|
||||
|
||||
test("typed password requires 12 characters", async ({ page }) => {
|
||||
await enterMachineBackup(page);
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await openPasswordBackup(page);
|
||||
|
||||
const create = page.getByTestId("encrypted-backup-create");
|
||||
await expect(create).toBeDisabled(); // empty field
|
||||
@@ -345,12 +353,10 @@ 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: Next still advances into the
|
||||
// download step, and Skip there continues to setup.
|
||||
// Keychain failure does not trap the user: Next still skips backup and
|
||||
// advances directly 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,8 +68,17 @@ 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();
|
||||
// Backup options leave the yellow flow for the dark security view without
|
||||
// adding a progress step or a generic Next action.
|
||||
await page.getByTestId("backup-options-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("onboarding-page-backup-options"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOT_DIR}/02c-backup-options.png` });
|
||||
|
||||
await page.getByTestId("backup-option-password").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
await page.getByTestId("onboarding-skip").click();
|
||||
await expect(
|
||||
|
||||
Reference in New Issue
Block a user