feat(onboarding): rework backup step into masked-key chooser with copy and download options

- BackupStep.tsx: replace the single Keycase card with a chooser view
  centered on a full-width masked key display:
  - Masked nsec with same-length monospace mask, inline reveal toggle
    (backup-key-reveal-toggle) and inline copy button (backup-copy-key)
    with spinner/checkmark feedback and an inline error banner
    (backup-copy-error) when the keychain fetch fails
  - Copy guidance ("save it somewhere safe — a password manager is a
    great place for it") moved into a tooltip on the copy icon
  - Security note restored below the key card
  - Encrypted download flow moved behind a footer CTA
    (backup-option-download) between Next and Back; opens a dedicated
    view with an "All backup options" back link (backup-back-to-options)
  - Raw key is fetched from the keychain only on explicit reveal/copy;
    the download path never fetches it
- EncryptedBackupCreator.tsx: de-brand "Keycase" to download/password
  terminology across headings, buttons, errors, and a11y labels
  ("Create Keycase" → "Encrypt and download", "Keycase saved to" →
  "Saved to", etc.)
- NsecMaskedDisplay.tsx: de-brand the ncryptsec kind label to
  "encrypted key"
- onboarding-backup.spec.ts: rewrite for the new UX — chooser
  masked-key/reveal/copy coverage (with clipboard permissions), download
  happy path behind the footer CTA, return-to-chooser navigation, and
  reveal error/retry paths replacing the old raw-key error tests
- onboarding-docked-cta-screenshots.spec.ts: capture the reveal toggle
  on the chooser instead of the removed backup-show-raw-key flow

Settings keeps its own Keycase wording; only the onboarding surface is
de-branded here.

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:
Taylor Ho
2026-07-28 15:33:47 -07:00
parent 88d0c59689
commit 60b7da6312
5 changed files with 333 additions and 208 deletions
+212 -111
View File
@@ -1,12 +1,14 @@
import { AlertTriangle, Info, RefreshCw } from "lucide-react";
import { ArrowLeft, Check, Copy, Eye, EyeOff, Info } from "lucide-react";
import * as React from "react";
import { getNsec } from "@/shared/api/tauriIdentity";
import { cn } from "@/shared/lib/cn";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
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 {
@@ -14,9 +16,7 @@ import {
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import { EncryptedBackupCreator } from "./EncryptedBackupCreator";
import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
export type BackupStepMode = "encrypted" | "raw";
import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay";
/**
* How long the "Creating your identity key" loader holds the stage before the
@@ -25,9 +25,44 @@ export type BackupStepMode = "encrypted" | "raw";
*/
const INTRO_HOLD_MS = 1400;
/**
* The creation moment should only be sold once per app session. Module-level
* so remounts (e.g. navigating Back and returning to this step) skip the fake
* hold and show the finished state instantly.
*/
let introPlayed = false;
const REVEAL_ANIMATION_CLASS =
"animate-in fade-in duration-700 motion-reduce:animate-none";
/** Quicker fade for switching between the chooser and a backup method. */
const VIEW_ANIMATION_CLASS =
"animate-in fade-in duration-300 motion-reduce:animate-none";
/**
* 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;
@@ -40,40 +75,33 @@ type BackupStepProps = {
};
/**
* Onboarding backup step — recommends a portable Keycase without blocking
* setup. The raw key is fetched only after the user chooses the advanced path.
* 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.
*/
export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
const [mode, setMode] = React.useState<BackupStepMode>("encrypted");
const [created, setCreated] = React.useState(introPlayed);
const [view, setView] = React.useState<BackupView>("choose");
const [copyState, setCopyState] = React.useState<
"idle" | "copying" | "copied"
>("idle");
const [copyError, setCopyError] = React.useState<string | null>(null);
const [nsec, setNsec] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [loadError, setLoadError] = React.useState<string | null>(null);
const [created, setCreated] = React.useState(false);
const [isRevealed, setIsRevealed] = React.useState(false);
const cancelledRef = React.useRef(false);
const copiedTimerRef = React.useRef<number | null>(null);
React.useEffect(() => {
const timer = window.setTimeout(() => setCreated(true), INTRO_HOLD_MS);
if (introPlayed) return;
const timer = window.setTimeout(() => {
introPlayed = true;
setCreated(true);
}, INTRO_HOLD_MS);
return () => window.clearTimeout(timer);
}, []);
const loadNsec = React.useCallback(async () => {
setIsLoading(true);
setLoadError(null);
try {
const value = await getNsec();
if (!cancelledRef.current) setNsec(value);
} catch (err) {
if (!cancelledRef.current)
setLoadError(
err instanceof Error
? err.message
: "Failed to retrieve private key.",
);
} finally {
if (!cancelledRef.current) setIsLoading(false);
}
}, []);
React.useEffect(() => {
cancelledRef.current = false;
return () => {
@@ -81,13 +109,61 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
// nsec from memory on unmount (backup step is only on the fresh-key path).
cancelledRef.current = true;
setNsec(null);
if (copiedTimerRef.current !== null)
window.clearTimeout(copiedTimerRef.current);
};
}, []);
const showRawKey = React.useCallback(() => {
setMode("raw");
void loadNsec();
}, [loadNsec]);
const copyKeyToClipboard = React.useCallback(async () => {
setCopyState("copying");
setCopyError(null);
try {
const value = nsec ?? (await getNsec());
await writeTextToClipboard(value);
if (cancelledRef.current) return;
setCopyState("copied");
if (copiedTimerRef.current !== null)
window.clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = window.setTimeout(() => {
if (!cancelledRef.current) setCopyState("idle");
}, 2000);
} catch (err) {
if (cancelledRef.current) return;
setCopyState("idle");
setCopyError(
err instanceof Error ? err.message : "Failed to retrieve private key.",
);
}
}, [nsec]);
const toggleReveal = React.useCallback(async () => {
if (isRevealed) {
setIsRevealed(false);
return;
}
setCopyError(null);
try {
// The raw key enters the DOM only after this explicit reveal action.
const value = nsec ?? (await getNsec());
if (cancelledRef.current) return;
setNsec(value);
setIsRevealed(true);
} catch (err) {
if (cancelledRef.current) return;
setCopyError(
err instanceof Error ? err.message : "Failed to retrieve private key.",
);
}
}, [isRevealed, nsec]);
// Fixed-length decorative mask (nsec keys are 63 chars) so no key material
// is fetched just to render the blurred row. Bullets are joined with a
// zero-width space: WebKit won't line-break a run of U+2022 without an
// explicit break opportunity, so the masked row would overflow otherwise.
const maskedKey = React.useMemo(
() => Array.from({ length: nsec?.length ?? 63 }, () => "•").join("\u200b"),
[nsec],
);
return (
<OnboardingSlideTransition
@@ -141,85 +217,110 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
REVEAL_ANIMATION_CLASS,
)}
>
{mode === "encrypted" ? (
<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">Save a Keycase</h2>
<p className="text-xs leading-5 text-muted-foreground">
A Keycase is still private. Never publish or share it. You
need both the file and password to restore your identity.
</p>
{view === "choose" ? (
<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="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>
<EncryptedBackupCreator variant="spotlight" />
</div>
</Card>
) : isLoading ? (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-foreground/70">
<Spinner className="h-4 w-4 border-2" />
Loading your private key…
</div>
) : loadError ? (
<div className="mx-auto max-w-[500px] space-y-3 text-left">
<div
className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
data-testid="backup-load-error"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
{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 &gt; Profile &gt;
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>
Could not retrieve your private key: {loadError}. You can
continue and find it later in Settings &gt; Profile &gt;
Identity.
Never share your private key. Anyone with this key can
impersonate you and access everything in your account.
</span>
</div>
<Button
className="h-8 gap-1.5 text-sm"
data-testid="backup-retry"
onClick={() => void loadNsec()}
size="sm"
type="button"
variant="outline"
>
<RefreshCw className="h-3.5 w-3.5" />
Try again
</Button>
</p>
</div>
) : nsec ? (
<Card className="w-full px-8 py-6" variant="textured">
<div className="mx-auto w-full max-w-[832px]">
<NsecMaskedDisplay nsec={nsec} variant="bare" />
</div>
</Card>
) : (
<p className="text-center text-sm text-foreground/70">
No key available to back up.
</p>
)}
{mode === "encrypted" ? (
<div className="mt-4 flex justify-center">
<Button
className="h-8 text-sm text-muted-foreground hover:text-accent-foreground"
data-testid="backup-show-raw-key"
onClick={showRawKey}
size="sm"
type="button"
variant="ghost"
>
Advanced: show my private key instead
</Button>
<div className={VIEW_ANIMATION_CLASS}>
<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={() => setView("choose")} />
</div>
) : null}
{mode === "raw" && nsec ? (
<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>
) : null}
)}
</div>
)}
@@ -235,15 +336,15 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
Next
</Button>
{mode === "raw" && loadError ? (
{view === "choose" ? (
<Button
className="h-9 rounded-full px-5 text-muted-foreground hover:text-accent-foreground"
data-testid="backup-skip"
onClick={onNext}
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="backup-option-download"
onClick={() => setView("download")}
type="button"
variant="ghost"
>
Skip for now
Download my key
</Button>
) : null}
@@ -23,14 +23,15 @@ import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
type EncryptedBackupCreatorProps = {
/** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */
variant?: "spotlight" | "boxed";
/** Fired only after the portable Keycase has been saved successfully. */
/** Fired only after the encrypted key file has been saved successfully. */
onSaved?: (path: string) => void;
};
/**
* Password-first Keycase creation flow shared by onboarding and Settings.
* The raw private key never enters this component. Rust creates the NIP-49
* payload locally, then the native save dialog produces the user-owned file.
* Password-first encrypted key download flow shared by onboarding and
* Settings. The raw private key never enters this component. Rust creates the
* NIP-49 payload locally, then the native save dialog produces the user-owned
* file.
*/
export function EncryptedBackupCreator({
variant = "spotlight",
@@ -57,7 +58,7 @@ export function EncryptedBackupCreator({
message:
err instanceof Error
? err.message
: "Failed to generate a Keycase password.",
: "Failed to generate a password.",
});
}
}, []);
@@ -89,7 +90,7 @@ export function EncryptedBackupCreator({
} catch (err) {
if (mountedRef.current)
setSaveError(
err instanceof Error ? err.message : "Failed to save Keycase.",
err instanceof Error ? err.message : "Failed to save your key.",
);
} finally {
if (mountedRef.current) setIsSaving(false);
@@ -99,7 +100,7 @@ export function EncryptedBackupCreator({
dispatch({
type: "create-failed",
message:
err instanceof Error ? err.message : "Failed to create Keycase.",
err instanceof Error ? err.message : "Failed to encrypt your key.",
});
}
}, [onSaved, state]);
@@ -117,7 +118,7 @@ export function EncryptedBackupCreator({
} catch (err) {
if (mountedRef.current)
setSaveError(
err instanceof Error ? err.message : "Failed to save Keycase.",
err instanceof Error ? err.message : "Failed to save your key.",
);
} finally {
if (mountedRef.current) setIsSaving(false);
@@ -149,14 +150,14 @@ export function EncryptedBackupCreator({
variant="outline"
>
{isSaving ? <Spinner className="h-3.5 w-3.5 border-2" /> : null}
Save Keycase…
Save a copy…
</Button>
{savedPath ? (
<p
className="text-xs text-muted-foreground"
data-testid="encrypted-backup-saved-path"
>
Keycase saved to {savedPath}
Saved to {savedPath}
</p>
) : null}
</div>
@@ -164,7 +165,7 @@ export function EncryptedBackupCreator({
<p className="text-center text-sm text-destructive">{saveError}</p>
) : null}
<p className="text-center text-xs leading-5 text-muted-foreground">
Keep this Keycase private. You need both the file and its password to
Keep this file private. You need both the file and its password to
restore your identity. Buzz cannot reset the password.
</p>
</div>
@@ -193,9 +194,7 @@ export function EncryptedBackupCreator({
data-testid="backup-passphrase-generate-error"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Could not generate a Keycase password: {state.generateError}
</span>
<span>Could not generate a password: {state.generateError}</span>
</div>
) : (
<div className="flex items-center justify-center gap-2 py-4 text-sm text-foreground/70">
@@ -227,15 +226,15 @@ export function EncryptedBackupCreator({
</Button>
</div>
<p className="text-center text-xs leading-5 text-muted-foreground">
Save this generated passphrase as your Keycase password. Store it
separately from the private Keycase file.
Save this generated passphrase as your encryption password. Store it
separately from the downloaded key file.
</p>
</div>
) : (
<div className="space-y-3">
<div className="space-y-2">
<Input
aria-label="Keycase password"
aria-label="Encryption password"
autoComplete="new-password"
className="h-10 bg-background"
data-testid="backup-passphrase-custom"
@@ -250,7 +249,7 @@ export function EncryptedBackupCreator({
value={state.customPassphrase}
/>
<Input
aria-label="Confirm Keycase password"
aria-label="Confirm encryption password"
autoComplete="new-password"
className="h-10 bg-background"
data-testid="backup-passphrase-confirm"
@@ -286,7 +285,8 @@ export function EncryptedBackupCreator({
</Button>
</div>
<p className="text-center text-xs leading-5 text-muted-foreground">
Your password protects the Keycase. Buzz cannot reset it if lost.
Your password protects your downloaded key. Buzz cannot reset it if
lost.
</p>
</div>
)}
@@ -311,10 +311,10 @@ export function EncryptedBackupCreator({
{state.isCreating ? (
<>
<Spinner className="h-4 w-4 border-2" />
Creating Keycase… this takes a couple of seconds
Encrypting… this takes a couple of seconds
</>
) : (
"Create Keycase"
"Encrypt and download"
)}
</Button>
</div>
@@ -9,8 +9,8 @@ type NsecMaskedDisplayProps = {
variant?: "boxed" | "bare";
/**
* What kind of secret is displayed. Drives labels, aria and testids:
* a raw private key ("nsec", default) can impersonate its holder; a
* Keycase ("ncryptsec") is only as sensitive as its passphrase.
* a raw private key ("nsec", default) can impersonate its holder; an
* encrypted key ("ncryptsec") is only as sensitive as its passphrase.
*/
kind?: "nsec" | "ncryptsec";
/**
@@ -26,7 +26,7 @@ const KIND_LABELS = {
testIdPrefix: "nsec",
},
ncryptsec: {
noun: "Keycase",
noun: "encrypted key",
testIdPrefix: "ncryptsec",
},
} as const;
+93 -68
View File
@@ -11,6 +11,14 @@ async function enterMachineBackup(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Create a new identity key" }).click();
}
async function invokedCommands(page: import("@playwright/test").Page) {
return page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
);
}
const SHOTS = "test-results/screenshots-onboarding";
test("backup step appears on fresh-key path after profile submit", async ({
@@ -36,24 +44,70 @@ test("backup step appears on fresh-key path after profile submit", async ({
});
// ---------------------------------------------------------------------------
// Keycase path: password → create locally → native save → saved confirmation.
// The raw key must never be fetched on this path.
// Chooser: masked key with reveal toggle and inline copy. The raw key is
// fetched only on explicit reveal/copy, and Next is never blocked.
// ---------------------------------------------------------------------------
test("Keycase happy path: generated password, create, native save, Next", async ({
test("chooser shows masked key; reveal and copy fetch it explicitly", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await enterMachineBackup(page);
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
// Masked by default: decorative mask only, no key material in the DOM.
const key = page.getByTestId("backup-key-value");
await expect(key).toBeVisible();
await expect(key).toHaveClass(/blur/);
await expect(key).not.toContainText("nsec1");
expect(await invokedCommands(page)).not.toContain("get_nsec");
// Reveal fetches the key; box must not reflow (same-length monospace mask).
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(key).toContainText("nsec1mock");
await expect(key).toHaveClass(/select-text/);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/02-backup-chooser-revealed.png` });
// Hide again.
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(key).not.toContainText("nsec1");
// Inline copy goes straight to the clipboard.
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");
// Backup is recommended, never required.
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
// ---------------------------------------------------------------------------
// Encrypted download path: 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 ({
page,
}) => {
await enterMachineBackup(page);
// The download flow sits behind the footer CTA.
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
await page.getByTestId("backup-option-download").click();
// Default mode: generated password shown, but backup remains optional.
await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
// Let the perceived-loading intro (animated logo → content fade-in) finish
// so the screenshot captures fully opaque content.
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/02-backup-step-passphrase.png` });
await page.screenshot({ path: `${SHOTS}/03-backup-download-passphrase.png` });
await page.getByTestId("encrypted-backup-create").click();
@@ -65,18 +119,14 @@ test("Keycase happy path: generated password, create, native save, Next", async
await expect(blob).toContainText("ncryptsec1");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/03-backup-step-encrypted.png` });
await page.screenshot({ path: `${SHOTS}/04-backup-download-encrypted.png` });
await expect(page.getByTestId("encrypted-backup-saved-path")).toContainText(
"identity.ncryptsec",
);
// The default path must never have fetched the raw key.
const commands = await page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
);
// The download path must never have fetched the raw key.
const commands = await invokedCommands(page);
expect(commands).not.toContain("get_nsec");
expect(commands).toContain("create_ncryptsec_backup");
@@ -85,10 +135,26 @@ test("Keycase happy path: generated password, create, native save, Next", async
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
test("download view returns to the chooser via All backup options", async ({
page,
}) => {
await enterMachineBackup(page);
await page.getByTestId("backup-option-download").click();
await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible();
// The footer download CTA hides while the flow is open.
await expect(page.getByTestId("backup-option-download")).toHaveCount(0);
await page.getByTestId("backup-back-to-options").click();
await expect(page.getByTestId("backup-key-value")).toBeVisible();
await expect(page.getByTestId("backup-option-download")).toBeVisible();
});
test("custom passphrase requires 12 characters and confirmation", async ({
page,
}) => {
await enterMachineBackup(page);
await page.getByTestId("backup-option-download").click();
await page.getByTestId("backup-passphrase-choose-own").click();
const create = page.getByTestId("encrypted-backup-create");
@@ -108,41 +174,6 @@ test("custom passphrase requires 12 characters and confirmation", async ({
await expect(create).toBeEnabled();
});
// ---------------------------------------------------------------------------
// Raw-key path: preserved behind one explicit advanced action.
// ---------------------------------------------------------------------------
test("raw key path is one explicit click away and shows the masked nsec", async ({
page,
}) => {
await enterMachineBackup(page);
await page.getByTestId("backup-show-raw-key").click();
const nsecDisplay = page.getByTestId("nsec-value");
await expect(nsecDisplay).toBeVisible();
// Should start masked (blurred) — reveal button exists and eye icon visible.
const revealBtn = page.getByTestId("nsec-reveal-toggle");
await expect(revealBtn).toBeVisible();
await expect(nsecDisplay).toHaveCSS("filter", /blur/);
// Reveal and verify the mock nsec appears.
await revealBtn.click();
await expect(nsecDisplay).not.toHaveCSS("filter", /blur/);
await expect(nsecDisplay).toContainText("nsec1mock");
// Intro crossfade must be finished before capturing.
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-backup-step-raw-revealed.png` });
// Next remains enabled on the advanced raw-key path.
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
test("backup step back button returns to machine identity choice", async ({
page,
}) => {
@@ -162,10 +193,10 @@ test("backup step back button returns to machine identity choice", async ({
});
// ---------------------------------------------------------------------------
// B4: Error path coverage (raw path)
// B4: Error path coverage (reveal/copy)
// ---------------------------------------------------------------------------
test("raw path shows error banner and retry button when get_nsec fails", async ({
test("reveal shows inline error when get_nsec fails and Next still advances", async ({
page,
}) => {
await installMockBridge(
@@ -177,22 +208,16 @@ test("raw path shows error banner and retry button when get_nsec fails", async (
await page.getByRole("button", { name: "Create a new identity key" }).click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await page.getByTestId("backup-show-raw-key").click();
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(page.getByTestId("backup-load-error")).toBeVisible();
await expect(page.getByTestId("backup-retry")).toBeVisible();
// Keychain failure does not trap the user; both Next and explicit skip work.
await expect(page.getByTestId("backup-copy-error")).toBeVisible();
// Keychain failure does not trap the user.
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await expect(page.getByTestId("backup-skip")).toBeVisible();
// Skip for now still advances to machine setup.
await page.getByTestId("backup-skip").click();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
test("raw path retry succeeds and shows key after initial failure", async ({
page,
}) => {
test("reveal retry succeeds after initial failure", async ({ page }) => {
// First call fails, second succeeds (sequenced via nsecErrors).
await installMockBridge(
page,
@@ -201,12 +226,12 @@ test("raw path retry succeeds and shows key after initial failure", async ({
);
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await page.getByTestId("backup-show-raw-key").click();
await expect(page.getByTestId("backup-load-error")).toBeVisible();
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(page.getByTestId("backup-copy-error")).toBeVisible();
// Retry — second call succeeds.
await page.getByTestId("backup-retry").click();
await expect(page.getByTestId("nsec-value")).toBeVisible();
await expect(page.getByTestId("backup-load-error")).not.toBeVisible();
// Retry — second call succeeds and clears the error.
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(page.getByTestId("backup-key-value")).toContainText("nsec1mock");
await expect(page.getByTestId("backup-copy-error")).not.toBeVisible();
});
@@ -59,13 +59,12 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02-backup.png` });
// Encrypted-by-default backup: the raw key sits behind an explicit click.
await page.getByTestId("backup-show-raw-key").click();
await expect(page.getByTestId("nsec-value")).toBeVisible();
// The key stays masked behind an explicit reveal toggle.
await expect(page.getByTestId("backup-key-value")).toBeVisible();
// Reveal the key: box must not reflow (same-length monospace mask).
await page.getByTestId("nsec-reveal-toggle").click();
await expect(page.getByTestId("nsec-value")).toHaveClass(/select-text/);
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(page.getByTestId("backup-key-value")).toHaveClass(/select-text/);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02b-backup-revealed.png` });