mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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 > 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>
|
||||
Could not retrieve your private key: {loadError}. You can
|
||||
continue and find it later in Settings > Profile >
|
||||
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;
|
||||
|
||||
@@ -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` });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user