+ );
+}
diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx
index ed2184baaa..99d9c6324d 100644
--- a/desktop/src/features/onboarding/ui/BackupStep.tsx
+++ b/desktop/src/features/onboarding/ui/BackupStep.tsx
@@ -1,183 +1,438 @@
-import { AlertTriangle, Info, RefreshCw } from "lucide-react";
+import { Check, Copy, Eye, EyeOff, Info, ShieldCheck } from "lucide-react";
+import { useReducedMotion } from "motion/react";
import * as React from "react";
import { getNsec } from "@/shared/api/tauriIdentity";
+import type { IdentityStorage } from "@/shared/api/types";
+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 { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import {
+ ONBOARDING_PRIMARY_CTA_CLASS,
+ ONBOARDING_SECONDARY_CTA_CLASS,
+} from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
-import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
+import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay";
/**
- * Pure helper so the disabled logic can be unit-tested without a DOM.
- *
- * Disabled while loading (key not fetched yet) or after a failed load (only
- * the explicit "Skip for now" ghost advances past an error).
+ * How long the "Creating your identity key" loader holds the stage before the
+ * finished state fades in. Purely perceptual — the key already exists; the
+ * pause sells the creation moment.
*/
-export function backupNextDisabled({
- isLoading,
- loadError,
-}: {
- isLoading: boolean;
- loadError: string | null;
-}): boolean {
- return isLoading || loadError !== null;
+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";
+
+const BACKUP_OPTION_CLASS =
+ "flex min-h-48 w-full flex-col items-start justify-start px-6 py-5 text-left text-foreground";
+
+/** Viewing the key never blocks onboarding — Next is always actionable. */
+export function backupNextDisabled(): boolean {
+ return false;
}
type BackupStepProps = {
direction: OnboardingTransitionDirection;
+ identityStorage?: IdentityStorage;
onBack: () => void;
onNext: () => void;
+ onOpenPasswordBackup: () => void;
+ onShowOptions: () => void;
+ optionsExpanded: boolean;
+ returningFromSecurity: boolean;
};
/**
- * Onboarding backup step — shows the user their freshly created key so they
- * can save it somewhere safe. Only shown on the fresh-key path.
+ * 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, onNext }: BackupStepProps) {
+export function BackupStep({
+ direction,
+ identityStorage,
+ onBack,
+ onNext,
+ onOpenPasswordBackup,
+ onShowOptions,
+ optionsExpanded,
+ returningFromSecurity,
+}: BackupStepProps) {
+ const reduceMotion = useReducedMotion() ?? false;
+ const [created, setCreated] = React.useState(introPlayed || reduceMotion);
+ const [copyState, setCopyState] = React.useState<
+ "idle" | "copying" | "copied"
+ >("idle");
+ const [copyError, setCopyError] = React.useState(null);
const [nsec, setNsec] = React.useState(null);
- const [isLoading, setIsLoading] = React.useState(true);
- const [loadError, setLoadError] = React.useState(null);
+ const [isRevealed, setIsRevealed] = React.useState(false);
const cancelledRef = React.useRef(false);
+ const copiedTimerRef = React.useRef(null);
- 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(() => {
+ if (introPlayed) return;
+ if (reduceMotion) {
+ introPlayed = true;
+ setCreated(true);
+ return;
}
- }, []);
+ const timer = window.setTimeout(() => {
+ introPlayed = true;
+ setCreated(true);
+ }, INTRO_HOLD_MS);
+ return () => window.clearTimeout(timer);
+ }, [reduceMotion]);
React.useEffect(() => {
cancelledRef.current = false;
- void loadNsec();
return () => {
// Back-during-fetch: cancel any in-flight setState calls and clear the
// 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);
};
- }, [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],
+ );
+ const storageDescription =
+ identityStorage === "system-keyring"
+ ? "Buzz keeps your identity key in your system keychain. Your computer may ask for your password when Buzz needs to read the key."
+ : identityStorage === "local-file"
+ ? "Your system keychain wasn’t available, so Buzz keeps your identity key in a private file on this device."
+ : "Buzz keeps your identity key protected on this device. Make a separate backup in case you lose access.";
+ const storageTitle =
+ identityStorage === "system-keyring"
+ ? "Protected by your system keychain"
+ : identityStorage === "local-file"
+ ? "Stored in private device storage"
+ : "Protected in private device storage";
+ const introStorageDescription =
+ identityStorage === "system-keyring"
+ ? "Buzz keeps your identity key in your system keychain."
+ : identityStorage === "local-file"
+ ? "Buzz keeps your identity key in a private file on this device because the system keychain wasn’t available."
+ : "Your identity key is protected on this device.";
+
+ if (optionsExpanded) {
+ return (
+
+
+
+ Backup options
+
+
+ Your identity key works like a password for your Buzz account. Keep
+ a copy somewhere safe. You can create a backup file and lock it with
+ a password you can remember.
+
+
+
+
+
+
+ {storageTitle}
+
+ {storageDescription}
+
+
+
+
+
+ Saved in your password manager
+
+
+ Copy your identity key, then save it in a password manager like
+ 1Password.
+
+
+
+
+
+
+ Locked in a backup file
+
+
+ Create a backup file and choose a password you can remember.
+ You’ll need both to restore your account.
+
+
+
+
+
+ {copyError ? (
+
+ Could not retrieve your private key: {copyError}. You can continue
+ and find it later in Settings > Profile > Identity.
+
+ ) : null}
+
+
+ );
+ }
return (
-
- Your unique identity key has been created
+ {/* Plain string concat: cn()'s tailwind-merge misreads the custom
+ text-title size token as conflicting with text-foreground. */}
+
+ {created
+ ? "Your unique identity key has been created"
+ : "Creating your identity key"}
-
- This key is stored in your system keychain, but save it some place
- safe in case you ever need to restore your account.
-
-
-
-
- {isLoading ? (
-
-
- Loading your private key…
-
- ) : loadError ? (
-
-
-
-
- Could not retrieve your private key: {loadError}. You can
- continue and find it later in Settings > Profile >
- Identity.
-
-
-
- ) : nsec ? (
-
-
-
-
-
- ) : (
-
- No key available to back up.
-
- )}
-
- {nsec ? (
-
-
-
- Never share your private key. Anyone with this key can impersonate
- you and access everything in your account.
-
+ review backup options
+ {" "}
+ for ways to restore your account.
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx
new file mode 100644
index 0000000000..3d69150049
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx
@@ -0,0 +1,139 @@
+import { motion, useReducedMotion } from "motion/react";
+import * as React from "react";
+
+import { Button } from "@/shared/ui/button";
+import {
+ ONBOARDING_SECURITY_PRIMARY_CTA_CLASS,
+ ONBOARDING_SECONDARY_CTA_CLASS,
+} from "./OnboardingChrome";
+import { OnboardingFooter } from "./OnboardingFooter";
+import {
+ type OnboardingTransitionDirection,
+ OnboardingSlideTransition,
+} from "./OnboardingSlideTransition";
+import {
+ type EncryptedBackupSession,
+ EncryptedBackupCreator,
+} from "./EncryptedBackupCreator";
+
+type DownloadKeyStepProps = {
+ direction: OnboardingTransitionDirection;
+ /** Backup state owned by the parent flow across the creation and test views. */
+ session: EncryptedBackupSession;
+ onBack: () => void;
+};
+
+/**
+ * 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,
+}: DownloadKeyStepProps) {
+ const reduceMotion = useReducedMotion() ?? false;
+ // Once the encrypted payload is saved, the creator advances to its guided
+ // backup test while this surface keeps its own navigation.
+ const hasCreated = session.created;
+ const hasVerifiedBackup = session.verified;
+ const hasSelectedBackup = session.test.stage === "password";
+ const [primaryActionSlot, setPrimaryActionSlot] =
+ React.useState(null);
+
+ return (
+
+
+ {/* Plain string concat: cn()'s tailwind-merge misreads the custom
+ text-title size token as conflicting with text-foreground. */}
+
+ {hasVerifiedBackup
+ ? "Your backup is verified"
+ : hasSelectedBackup
+ ? "That’s your backup file"
+ : hasCreated
+ ? "Optionally, test your backup"
+ : "Backup your key with a password"}
+
+
+ {hasVerifiedBackup
+ ? "Your file and password can restore your identity."
+ : hasSelectedBackup
+ ? "Now enter your password to prove you can unlock it."
+ : hasCreated
+ ? "Learn how your backup works. Drop the file you just saved and unlock it with your password."
+ : "Keep the downloaded file private — you need both it and your password to restore your identity. Save the backup password somewhere safe; Buzz cannot reset it if lost."}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {hasVerifiedBackup ? "Finish" : hasCreated ? "Skip for now" : "Back"}
+
+
+
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx
new file mode 100644
index 0000000000..bb76166bd7
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx
@@ -0,0 +1,885 @@
+import { AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react";
+import * as React from "react";
+import { createPortal } from "react-dom";
+
+import {
+ createNcryptsecBackup,
+ generateBackupPassphrase,
+ saveNcryptsecCopy,
+} from "@/shared/api/tauriIdentity";
+import { cn } from "@/shared/lib/cn";
+import { Button } from "@/shared/ui/button";
+import { Input } from "@/shared/ui/input";
+import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
+import { Spinner } from "@/shared/ui/spinner";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/shared/ui/alert-dialog";
+import {
+ downloadDisabled,
+ passphraseIssue,
+ pendingEncryptPassphrase,
+ encryptedBackupReducer,
+ initialEncryptedBackupState,
+ MIN_PASSPHRASE_LEN,
+ type EncryptedBackupEvent,
+ type EncryptedBackupState,
+} from "../lib/encryptedBackup";
+import {
+ type BackupTestProgress,
+ BackupTestFlow,
+ initialBackupTestProgress,
+} from "./BackupTestFlow";
+import { BackupPasswordTimeline } from "./BackupPasswordTimeline";
+import {
+ ONBOARDING_SECURITY_PRIMARY_CTA_CLASS,
+ ONBOARDING_SECONDARY_CTA_CLASS,
+} from "./OnboardingChrome";
+
+/** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */
+const MIN_GENERATED_WORDS = 3;
+const MAX_GENERATED_WORDS = 10;
+const DEFAULT_GENERATED_WORDS = 3;
+
+const SEPARATOR_OPTIONS = [
+ { label: "Spaces", value: " " },
+ { label: "Hyphens", value: "-" },
+ { label: "Periods", value: "." },
+ { label: "Commas", value: "," },
+] as const;
+
+const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value;
+
+/**
+ * Pause after the last keystroke before the background KDF starts, so typing
+ * past the minimum length doesn't launch an encryption per character.
+ */
+const ENCRYPT_DEBOUNCE_MS = 400;
+
+const PENDING_TICKER_MESSAGES = [
+ "Downloading once finished",
+ "Encrypting your password",
+ "Just a bit longer...",
+] as const;
+
+/** How long each ticker message holds before sliding to the next. */
+const PENDING_TICKER_INTERVAL_MS = 2500;
+
+/** Matches the `duration-300` slide transition on the ticker column. */
+const PENDING_TICKER_SLIDE_MS = 300;
+
+/**
+ * Vertical ticker for the queued-download button label — cycles through the
+ * pending messages by sliding a stacked column inside a one-line viewport.
+ * The column ends with a clone of the first message, so the wrap-around
+ * slides up from the bottom like every other step; once the clone settles,
+ * the column snaps (transition disabled) back to the real first row. All
+ * lines render at all times, so the button keeps the width of the longest
+ * message instead of resizing on each swap.
+ */
+function PendingDownloadTicker() {
+ // Index into the rendered column (messages + trailing clone of the first).
+ const [position, setPosition] = React.useState(0);
+ const [snap, setSnap] = React.useState(false);
+
+ React.useEffect(() => {
+ const timer = window.setInterval(
+ () => setPosition((current) => current + 1),
+ PENDING_TICKER_INTERVAL_MS,
+ );
+ return () => window.clearInterval(timer);
+ }, []);
+
+ // The clone is visually identical to the first message: once its slide-in
+ // finishes, jump back to the real first row without animating.
+ React.useEffect(() => {
+ if (position !== PENDING_TICKER_MESSAGES.length) return;
+ const timer = window.setTimeout(() => {
+ setSnap(true);
+ setPosition(0);
+ }, PENDING_TICKER_SLIDE_MS);
+ return () => window.clearTimeout(timer);
+ }, [position]);
+
+ // Re-enable the transition one frame after the snap has painted.
+ React.useEffect(() => {
+ if (!snap) return;
+ const raf = window.requestAnimationFrame(() => setSnap(false));
+ return () => window.cancelAnimationFrame(raf);
+ }, [snap]);
+
+ // The clone row duplicates the first message's text, so it carries its own
+ // stable key.
+ const column = [
+ ...PENDING_TICKER_MESSAGES.map((message) => ({ key: message, message })),
+ { key: "wrap-clone", message: PENDING_TICKER_MESSAGES[0] },
+ ];
+
+ return (
+
+
+ {column.map((row) => (
+
+ {row.message}
+
+ ))}
+
+
+ );
+}
+
+/**
+ * Everything about an in-progress backup that must survive this component
+ * unmounting: the reducer state (short-lived passphrase + encrypted blob), whether the
+ * backup test passed, where the file was saved, the save-once guard, and the
+ * test-flow progress. Hosts that need the state to outlive the creator (the
+ * onboarding flow, where Back unmounts the step) call
+ * `useEncryptedBackupSession` at a longer-lived level and pass it down;
+ * otherwise the creator owns a private session internally.
+ */
+export type EncryptedBackupSession = {
+ state: EncryptedBackupState;
+ dispatch: React.Dispatch;
+ /**
+ * True once the encrypted payload has been committed AND saved to disk.
+ * Derived so hosts (e.g. DownloadKeyStep) can branch on it without touching
+ * the blob itself — keeping them outside the ncryptsec confinement scan.
+ */
+ created: boolean;
+ /** True once the user has passed the backup test. */
+ verified: boolean;
+ setVerified: React.Dispatch>;
+ savedPath: string | null;
+ setSavedPath: React.Dispatch>;
+ /** The committed blob a save was already kicked off for (save-once guard). */
+ savedForRef: React.MutableRefObject;
+ test: BackupTestProgress;
+ setTest: React.Dispatch>;
+};
+
+/** Host-side state for `EncryptedBackupCreator` — see `EncryptedBackupSession`. */
+export function useEncryptedBackupSession(): EncryptedBackupSession {
+ const [state, dispatch] = React.useReducer(
+ encryptedBackupReducer,
+ initialEncryptedBackupState,
+ );
+ const [verified, setVerified] = React.useState(false);
+ const [savedPath, setSavedPath] = React.useState(null);
+ const savedForRef = React.useRef(null);
+ const [test, setTest] = React.useState(
+ initialBackupTestProgress,
+ );
+ return React.useMemo(
+ () => ({
+ state,
+ dispatch,
+ created: state.ncryptsec !== null && savedPath !== null,
+ verified,
+ setVerified,
+ savedPath,
+ setSavedPath,
+ savedForRef,
+ test,
+ setTest,
+ }),
+ [state, verified, savedPath, test],
+ );
+}
+
+/**
+ * Return to a secure saved-password placeholder. The encrypted blob survives
+ * for instant re-download, while no password or test attempt is retained.
+ */
+export function backupSessionToPasswordEntry(
+ session: EncryptedBackupSession,
+): void {
+ session.dispatch({ type: "back-to-password" });
+ session.setVerified(false);
+ session.setSavedPath(null);
+ session.setTest(initialBackupTestProgress);
+}
+
+/** Discard all backup-creation and verification progress. */
+export function resetEncryptedBackupSession(
+ session: EncryptedBackupSession,
+): void {
+ session.dispatch({ type: "start-new-backup" });
+ session.setVerified(false);
+ session.setSavedPath(null);
+ session.savedForRef.current = null;
+ session.setTest(initialBackupTestProgress);
+}
+
+type EncryptedBackupCreatorProps = {
+ /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */
+ variant?: "spotlight" | "boxed";
+ /**
+ * When set, the "Download" button is portaled into this element instead of
+ * rendering inline.
+ */
+ createButtonPortal?: HTMLElement | null;
+ /** Optional onboarding footer target for the guided-test verification CTA. */
+ verifyButtonPortal?: HTMLElement | null;
+ /** Extra classes for the "Download" button. */
+ createButtonClassName?: string;
+ /**
+ * Host-owned session so the backup state survives this component
+ * unmounting (onboarding Back navigation). Omitted = private session.
+ */
+ session?: EncryptedBackupSession;
+ /** Fired once the encrypted payload has been created (before saving). */
+ onCreated?: () => void;
+ /** Fired only after the encrypted key file has been saved successfully. */
+ onSaved?: (path: string) => void;
+ /** Whether creation continues into onboarding's guided test ceremony. */
+ guidedTest?: boolean;
+ /** Fired once when the user completes the backup test successfully. */
+ onVerified?: () => void;
+};
+
+/**
+ * 1Password-style memorable-password generator popover with word-count and
+ * separator fields, anchored to a refresh icon inset in the password field
+ * (the anchor assumes a `relative` parent). The first click opens the
+ * popover and generates; further clicks on the icon re-roll while the
+ * popover stays open — only click-outside or Esc closes it. There is no
+ * candidate preview: every generation writes the passphrase straight into
+ * the parent's password field via `onGenerated`.
+ */
+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);
+ const [separator, setSeparator] = React.useState(DEFAULT_SEPARATOR);
+ const [error, setError] = React.useState(null);
+ const anchorRef = React.useRef(null);
+ const mountedRef = React.useRef(true);
+ // Read via a ref so `generate` stays reference-stable even though parents
+ // pass an inline `onGenerated`. Otherwise each generated password would
+ // re-render the parent, rebuild `generate`, and re-fire the open/controls
+ // effect below — an infinite generate loop while the popover is open.
+ const onGeneratedRef = React.useRef(onGenerated);
+
+ React.useEffect(() => {
+ onGeneratedRef.current = onGenerated;
+ }, [onGenerated]);
+
+ React.useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
+ const generate = React.useCallback(async (wordCount: number, sep: string) => {
+ setError(null);
+ try {
+ const passphrase = await generateBackupPassphrase({
+ words: wordCount,
+ separator: sep,
+ });
+ if (mountedRef.current) onGeneratedRef.current(passphrase);
+ } catch (err) {
+ if (!mountedRef.current) return;
+ setError(
+ err instanceof Error ? err.message : "Failed to generate a password.",
+ );
+ }
+ }, []);
+
+ // Fill the password field on every open and whenever a control changes.
+ React.useEffect(() => {
+ if (open) void generate(words, separator);
+ }, [open, words, separator, generate]);
+
+ return (
+
+ {/* Anchor (not Trigger): Radix triggers toggle on click, but repeat
+ clicks here must generate a fresh password while the popover stays
+ open. Only click-outside or Esc closes it. */}
+
+ {
+ // The open effect below generates the first password; later
+ // clicks re-roll with the current controls.
+ if (onRequestGenerate) {
+ onRequestGenerate();
+ return;
+ }
+ if (!open) setOpen(true);
+ else void generate(words, separator);
+ }}
+ ref={anchorRef}
+ size="icon"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+ {
+ // Clicking the anchor icon is "outside" the content — keep the
+ // popover open so that click re-rolls instead of closing.
+ if (
+ event.target instanceof Node &&
+ anchorRef.current?.contains(event.target)
+ ) {
+ event.preventDefault();
+ }
+ }}
+ onOpenAutoFocus={(event) => event.preventDefault()}
+ >
+
+ );
+ }
+ // Without the guided test (settings), a completed save keeps the form
+ // visible in its saved-password state: masked input, instant re-download,
+ // and the change-password confirmation guarding any edit.
+
+ return (
+
+ );
+ // `undefined` = inline (settings); `null` = slot not mounted yet
+ // (skip a frame rather than flashing the button inline).
+ if (createButtonPortal === undefined)
+ return
{createButton}
;
+ return createButtonPortal
+ ? createPortal(createButton, createButtonPortal)
+ : null;
+ })()}
+
+
+
+ Create a new backup password?
+
+ Starting over lets you pick a new password and download a fresh
+ backup file. Backups you saved earlier will still work — just use
+ the password you created them with.
+
+
+
+
+ Keep current backup
+
+ {
+ dispatch({ type: "start-new-backup" });
+ setSavedPath(null);
+ savedForRef.current = null;
+ setTest(initialBackupTestProgress);
+ setIsRevealed(false);
+ }}
+ >
+ Start with a new password
+
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx b/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx
index 0376fc9709..a6a02f38c0 100644
--- a/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx
+++ b/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx
@@ -22,8 +22,8 @@ export function KeyringLockedScreen() {
}, []);
const handleImport = React.useCallback(
- async (nsec: string) => {
- const identity = await importIdentity(nsec);
+ async (nsec: string, password?: string) => {
+ const identity = await importIdentity(nsec, password);
// Update the identity query cache so useIdentityQuery observers see
// locked: false. The bootedLocked latch in hooks.ts will then route
// to RelaunchRequiredScreen via bootedLocked && !identityLocked.
diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
index ca87c76636..cee17c68f8 100644
--- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
@@ -1,20 +1,33 @@
import * as React from "react";
import type { QueryClient } from "@tanstack/react-query";
+import { ArrowUp } from "lucide-react";
+import { motion, useReducedMotion } from "motion/react";
import {
getIdentity,
importIdentity,
persistCurrentIdentity,
} from "@/shared/api/tauriIdentity";
+import type { IdentityStorage } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { BackupStep } from "./BackupStep";
import { DefaultConfigStep } from "./DefaultConfigStep";
+import { DownloadKeyStep } from "./DownloadKeyStep";
+import {
+ backupSessionToPasswordEntry,
+ resetEncryptedBackupSession,
+ useEncryptedBackupSession,
+} from "./EncryptedBackupCreator";
import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog";
import { LandingBees } from "./LandingBees";
-import { NostrKeyImportForm } from "./NostrKeyImportForm";
+import {
+ NostrKeyImportForm,
+ type NostrKeyImportStage,
+} from "./NostrKeyImportForm";
import {
ONBOARDING_LANDING_CTA_CLASS,
+ ONBOARDING_SECONDARY_CTA_CLASS,
OnboardingChrome,
} from "./OnboardingChrome";
import { OnboardingFooterProvider } from "./OnboardingFooter";
@@ -28,6 +41,8 @@ export type MachineOnboardingPage =
| "setup"
| "config";
+type BackupSubview = "created" | "options" | "password";
+
/** A pending navigation the parent should execute after RouterProvider mounts. */
export type PostOnboardingNavigation = {
to: string;
@@ -61,10 +76,27 @@ export function MachineOnboardingFlow({
const [error, setError] = React.useState(null);
const [isPending, setIsPending] = React.useState(false);
const [identityWasImported, setIdentityWasImported] = React.useState(false);
+ const [keyImportStage, setKeyImportStage] =
+ React.useState("key-entry");
const [selectedPubkey, setSelectedPubkey] = React.useState(
null,
);
+ const [identityStorage, setIdentityStorage] = React.useState<
+ IdentityStorage | undefined
+ >();
const [readyRuntimeIds, setReadyRuntimeIds] = React.useState([]);
+ const [backupSubview, setBackupSubview] =
+ React.useState("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 reduceMotion = useReducedMotion() ?? false;
+ const isSecuritySubview = page === "backup" && backupSubview !== "created";
const handleReadyRuntimeIdsChange = React.useCallback(
(runtimeIds: readonly string[]) => {
setReadyRuntimeIds(Array.from(new Set(runtimeIds)));
@@ -79,6 +111,10 @@ export function MachineOnboardingFlow({
const identity = await getIdentity();
queryClient.setQueryData(["identity"], identity);
setSelectedPubkey(identity.pubkey);
+ setIdentityStorage(identity.storage);
+ setBackupDirection("forward");
+ setReturningFromSecurity(false);
+ setBackupSubview("created");
setPage("backup");
} catch (cause) {
setError(
@@ -101,6 +137,10 @@ export function MachineOnboardingFlow({
const identity = await persistCurrentIdentity();
queryClient.setQueryData(["identity"], identity);
setSelectedPubkey(identity.pubkey);
+ setIdentityStorage(identity.storage);
+ setBackupDirection("forward");
+ setReturningFromSecurity(false);
+ setBackupSubview("created");
setPage("backup");
} catch (cause) {
setError(
@@ -112,8 +152,8 @@ export function MachineOnboardingFlow({
}, [queryClient]);
const importExistingIdentity = React.useCallback(
- async (nsec: string) => {
- const identity = await importIdentity(nsec);
+ async (nsec: string, password?: string) => {
+ const identity = await importIdentity(nsec, password);
continueWithIdentity(identity.pubkey);
queryClient.setQueryData(["identity"], identity);
setIdentityWasImported(true);
@@ -126,6 +166,8 @@ export function MachineOnboardingFlow({
return (
- {identityLost
- ? "Re-import your key"
- : "Enter your private key"}
+ {keyImportStage === "backup-password"
+ ? "Unlock your account"
+ : identityLost
+ ? "Re-import your key"
+ : "Enter your private key"}
- {identityLost
- ? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
- : "If you already have a Buzz account, enter your private key below to get started."}
+ {keyImportStage === "backup-password"
+ ? "Enter your backup password to unlock your key and restore your identity."
+ : identityLost
+ ? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
+ : "If you already have a Buzz account, enter your private key below to get started."}
+ {isEncryptedInput
+ ? "Waiting for a complete ncryptsec backup"
+ : "Waiting for a valid nsec1 key"}
+
+ ) : null}
- {errorMessage ? (
-
{errorMessage}
- ) : null}
-
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+ ) : null}
- {backLabel}
+ {isPasswordStage ? "Back" : backLabel}
diff --git a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx
index 936313bce0..7a52ae4999 100644
--- a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx
+++ b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx
@@ -2,8 +2,8 @@ import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
/**
* Positions in the first-launch flow: landing, identity/key, harness setup,
- * default config, community choice, community profile, meet the team. Used as
- * the default pagination length when a flow doesn't pass an explicit total.
+ * 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 = 7;
@@ -17,6 +17,9 @@ const ONBOARDING_CTA_SHAPE = "h-[2.375rem] rounded-full px-6";
*/
export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-onboarding-cta-label)]`;
+/** Inverted primary action used only on dark backup-security surfaces. */
+export const ONBOARDING_SECURITY_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} bg-white text-black/80 hover:bg-white/90 hover:text-black`;
+
/**
* Primary-CTA styling for the landing screen only: the shared pill with the
* chartreuse label (`--buzz-welcome-chartreuse`). The blue label is reserved
@@ -24,6 +27,10 @@ export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(-
*/
export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-welcome-chartreuse)]`;
+/** Shared quiet pill for secondary actions throughout onboarding. */
+export const ONBOARDING_SECONDARY_CTA_CLASS =
+ "h-9 rounded-full bg-foreground/10 px-6 text-foreground hover:bg-foreground/15 hover:text-foreground";
+
/**
* Icon-control styling for onboarding surfaces that sit on the textured card:
* olive backup ink (`--buzz-onboarding-backup-ink`) with a plain
@@ -34,6 +41,10 @@ export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(-
export const ONBOARDING_INK_ICON_CLASS =
"text-[color:var(--buzz-onboarding-backup-ink)] hover:bg-transparent hover:text-foreground";
+/** Icon controls on the dark noisy backup surfaces stay visually unboxed. */
+export const ONBOARDING_SECURITY_ICON_CLASS =
+ "text-muted-foreground hover:bg-transparent hover:text-foreground";
+
/**
* Shared onboarding chrome shown on every page after the landing screen: a
* static Buzz mark pinned to the top-left, and a centered pagination track that
diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
index 01a226e3de..a3653f750f 100644
--- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
@@ -388,8 +388,8 @@ export function OnboardingFlow({
// key's relay profile reseeds the steps, and a key that already finished
// onboarding on this machine skips straight into the app.
const importExistingKey = React.useCallback(
- async (nsec: string) => {
- const identity = await importIdentity(nsec);
+ async (nsec: string, password?: string) => {
+ const identity = await importIdentity(nsec, password);
relayClient.disconnect();
queryClient.setQueryData(["identity"], identity);
queryClient.removeQueries({ queryKey: profileQueryKey });
diff --git a/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx b/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx
index 82d9a0213c..ba5d8b2c87 100644
--- a/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx
+++ b/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx
@@ -14,6 +14,7 @@ export type OnboardingTransitionDirection = "forward" | "backward";
export type OnboardingTransitionEffect =
| "fade"
| "line-slide"
+ | "mask-reveal-down"
| "mask-reveal-up"
| "none";
diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx
index 431c9b2f51..911ddaf362 100644
--- a/desktop/src/features/onboarding/ui/SetupStep.tsx
+++ b/desktop/src/features/onboarding/ui/SetupStep.tsx
@@ -698,25 +698,28 @@ function SetupStepContent({
/>
- actions.next(readyRuntimeIds)}
- type="button"
- >
- Next
-
-
- actions.next([])}
- type="button"
- variant="ghost"
- >
- Skip for now
-
+ {/* Relative row keeps the primary CTA truly centered while Skip
+ hangs off its right edge without shifting the center. */}
+
+ actions.next(readyRuntimeIds)}
+ type="button"
+ >
+ Next
+
+ actions.next([])}
+ type="button"
+ variant="ghost"
+ >
+ Skip for now
+
+
{
});
// ---------------------------------------------------------------------------
-// BackupStep gating: backupNextDisabled() pure helper
+// BackupStep gating: saving a password-protected backup is recommended, not required
// ---------------------------------------------------------------------------
-test("backup_next_disabled_while_loading", () => {
- // During a slow keychain read, Next must be blocked — user cannot race past
- // the key display before it is shown.
- assert.equal(backupNextDisabled({ isLoading: true, loadError: null }), true);
-});
-
-test("backup_next_disabled_on_load_error", () => {
- // Error state: only the explicit "Skip for now" ghost advances; Next blocked.
- assert.equal(
- backupNextDisabled({ isLoading: false, loadError: "IPC error" }),
- true,
- );
-});
-
-test("backup_next_enabled_after_clean_load", () => {
- // Key shown (or backend cleanly returned none) — user may proceed.
- assert.equal(
- backupNextDisabled({ isLoading: false, loadError: null }),
- false,
- );
+test("backup_next_is_always_enabled", () => {
+ assert.equal(backupNextDisabled(), false);
});
// ---------------------------------------------------------------------------
diff --git a/desktop/src/features/settings/ui/SignOutSection.tsx b/desktop/src/features/settings/ui/SignOutSection.tsx
index 746220459b..500875b6f0 100644
--- a/desktop/src/features/settings/ui/SignOutSection.tsx
+++ b/desktop/src/features/settings/ui/SignOutSection.tsx
@@ -31,13 +31,13 @@ export const SIGNOUT_CONFIRM_PHRASE = "wipe all my data";
* Signing out wipes the identity key and all local data, so the confirm
* dialog gates the delete button behind two explicit steps:
*
- * 1. Back up the key — the nsec is shown inline (masked, with reveal/copy);
- * the "I have saved my private key" checkbox unlocks only after the user
- * actually reveals or copies the key.
+ * 1. Confirm recovery — Settings offers a tested password-protected backup;
+ * the dialog also shows the raw nsec as a last-chance fallback, and the
+ * user checks a box confirming they can restore their identity.
* 2. Typed confirmation — the user must type the exact phrase
* "wipe all my data".
*
- * Only when both gates pass does "Delete My Data" become clickable.
+ * Only when both gates pass does "Delete my data" become clickable.
*/
export function SignOutSection() {
const [isOpen, setIsOpen] = React.useState(false);
@@ -47,7 +47,6 @@ export function SignOutSection() {
const [nsec, setNsec] = React.useState(null);
const [nsecError, setNsecError] = React.useState(null);
const [isNsecLoading, setIsNsecLoading] = React.useState(false);
- const [hasInteractedWithKey, setHasInteractedWithKey] = React.useState(false);
const [hasConfirmedBackup, setHasConfirmedBackup] = React.useState(false);
// Guards against a late-resolving getNsec() repopulating state after the
// dialog closes.
@@ -58,20 +57,13 @@ export function SignOutSection() {
const isPhraseConfirmed =
confirmText.trim().toLowerCase() === SIGNOUT_CONFIRM_PHRASE;
- // The backup checkbox unlocks after real interaction with the key
- // (reveal or copy). If the key cannot be loaded at all there is nothing to
- // interact with — let the user proceed past the backup step rather than
- // locking them out of sign-out entirely.
- const isBackupGateSatisfied = hasConfirmedBackup;
- const canConfirmBackup = hasInteractedWithKey || nsecError !== null;
- const canDelete = isBackupGateSatisfied && isPhraseConfirmed && !isPending;
+ const canDelete = hasConfirmedBackup && isPhraseConfirmed && !isPending;
function resetDialogState() {
fetchCancelledRef.current = true;
setNsec(null);
setNsecError(null);
setIsNsecLoading(false);
- setHasInteractedWithKey(false);
setHasConfirmedBackup(false);
setConfirmText("");
}
@@ -137,7 +129,8 @@ export function SignOutSection() {
Sign out
Removes your identity key and all local app data from this device.
- Back up your private key (nsec) first — this cannot be undone.
+ Before signing out, create and test a password-protected key backup
+ above — this cannot be undone.
- 1. Back up your private key (nsec)
+ 1. Confirm you can restore your identity
{isNsecLoading ? (
Loading…
@@ -187,10 +180,7 @@ export function SignOutSection() {
{nsecError}
) : nsec ? (
- setHasInteractedWithKey(true)}
- />
+
) : null}
diff --git a/desktop/src/shared/api/identityTypes.ts b/desktop/src/shared/api/identityTypes.ts
new file mode 100644
index 0000000000..9ae41d3345
--- /dev/null
+++ b/desktop/src/shared/api/identityTypes.ts
@@ -0,0 +1,28 @@
+export type IdentityStorage =
+ | "system-keyring"
+ | "local-file"
+ | "environment"
+ | "ephemeral";
+
+export type Identity = {
+ pubkey: string;
+ displayName: string;
+ /** Durable location of the active identity key. Older/mock bridges may omit
+ * this until they adopt identity storage reporting. */
+ storage?: IdentityStorage;
+ /** True when the app booted in "identity lost" recovery mode — the OS
+ * keyring was empty despite a prior successful migration. The frontend
+ * should route to nsec re-import instead of normal onboarding.
+ * Mutually exclusive with `locked`. */
+ lost?: boolean;
+ /** True when the app booted with an ephemeral key because the OS keyring
+ * holding the real identity is UNREACHABLE (e.g. GNOME Keyring / KWallet
+ * locked). The real key still exists; no in-app recovery is possible —
+ * the user must unlock the keyring externally and relaunch.
+ * Mutually exclusive with `lost`. */
+ locked?: boolean;
+ /** True when the boot-time Phase 2 reset attempted a wipe but verification
+ * failed. Identity resolution was skipped; the sentinel is preserved so
+ * the next relaunch retries the wipe automatically. */
+ resetFailed?: boolean;
+};
diff --git a/desktop/src/shared/api/tauriIdentity.ts b/desktop/src/shared/api/tauriIdentity.ts
index e6ec266bff..161f25c211 100644
--- a/desktop/src/shared/api/tauriIdentity.ts
+++ b/desktop/src/shared/api/tauriIdentity.ts
@@ -1,9 +1,10 @@
import { invokeTauri } from "@/shared/api/tauri";
-import type { Identity } from "@/shared/api/types";
+import type { Identity, IdentityStorage } from "@/shared/api/types";
type RawIdentity = {
pubkey: string;
display_name: string;
+ storage?: IdentityStorage;
lost?: boolean;
locked?: boolean;
reset_failed?: boolean;
@@ -13,6 +14,7 @@ function fromRawIdentity(raw: RawIdentity): Identity {
return {
pubkey: raw.pubkey,
displayName: raw.display_name,
+ storage: raw.storage,
lost: raw.lost === true,
locked: raw.locked === true,
resetFailed: raw.reset_failed === true,
@@ -27,9 +29,12 @@ export async function getNsec(): Promise {
return invokeTauri("get_nsec");
}
-export async function importIdentity(nsec: string): Promise {
+export async function importIdentity(
+ nsec: string,
+ password?: string,
+): Promise {
return fromRawIdentity(
- await invokeTauri("import_identity", { nsec }),
+ await invokeTauri("import_identity", { nsec, password }),
);
}
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index 877b5b1c61..3f07e9ad9a 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -103,25 +103,7 @@ export type AddChannelMembersResult = {
}>;
};
-export type Identity = {
- pubkey: string;
- displayName: string;
- /** True when the app booted in "identity lost" recovery mode — the OS
- * keyring was empty despite a prior successful migration. The frontend
- * should route to nsec re-import instead of normal onboarding.
- * Mutually exclusive with `locked`. */
- lost?: boolean;
- /** True when the app booted with an ephemeral key because the OS keyring
- * holding the real identity is UNREACHABLE (e.g. GNOME Keyring / KWallet
- * locked). The real key still exists; no in-app recovery is possible —
- * the user must unlock the keyring externally and relaunch.
- * Mutually exclusive with `lost`. */
- locked?: boolean;
- /** True when the boot-time Phase 2 reset attempted a wipe but verification
- * failed. Identity resolution was skipped; the sentinel is preserved so
- * the next relaunch retries the wipe automatically. */
- resetFailed?: boolean;
-};
+export type { Identity, IdentityStorage } from "./identityTypes";
export type Profile = {
pubkey: string;
diff --git a/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs b/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs
new file mode 100644
index 0000000000..9ca8b9acf5
--- /dev/null
+++ b/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs
@@ -0,0 +1,74 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import test from "node:test";
+
+// Structural tripwire (plan D4, defense-in-depth): NIP-49 backup material
+// handling in the webview is confined to the identity/backup/import UI and
+// its API wrappers. Anything else in `desktop/src` touching `ncryptsec` is
+// structural drift toward an unguarded egress path and must be reviewed —
+// the runtime guarantee lives in src-tauri's egress guard, this scan only
+// keeps the blob from quietly spreading through the frontend.
+//
+// Mirror of the Rust-side scan in
+// `src-tauri/src/egress_guard_tests.rs::ncryptsec_handling_is_confined_to_allowlisted_files`.
+
+const SRC_ROOT = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../..",
+);
+
+const ALLOWLIST = [
+ "shared/api/tauriIdentity.ts",
+ "features/onboarding/lib/encryptedBackup.ts",
+ "features/onboarding/lib/encryptedBackup.test.mjs",
+ "features/onboarding/lib/keyImportInput.ts",
+ "features/onboarding/lib/keyImportInput.test.mjs",
+ "features/onboarding/ui/BackupStep.tsx",
+ "features/onboarding/ui/BackupPasswordTimeline.tsx",
+ "features/onboarding/ui/BackupTestFlow.tsx",
+ "features/onboarding/ui/EncryptedBackupCreator.tsx",
+ "features/onboarding/ui/NostrKeyImportForm.tsx",
+ "features/onboarding/ui/NsecMaskedDisplay.tsx",
+ "features/settings/EncryptedBackupProvider.tsx",
+ "features/settings/lib/encryptedBackup.ts",
+ "features/settings/lib/encryptedBackup.test.mjs",
+ "features/settings/ui/BackupTestFlow.tsx",
+ "features/settings/ui/EncryptedBackupCreator.tsx",
+ "features/settings/ui/ProfileSettingsCard.tsx",
+ // e2e-only mock bridge (never in the production bundle):
+ "testing/e2eBridge.ts",
+ // this scan:
+ "shared/lib/ncryptsecSourceScan.test.mjs",
+];
+
+function* walk(dir) {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ yield* walk(full);
+ } else if (/\.(ts|tsx|mjs|js|jsx)$/.test(entry.name)) {
+ yield full;
+ }
+ }
+}
+
+test("ncryptsec handling is confined to allowlisted frontend files", () => {
+ const violations = [];
+ for (const file of walk(SRC_ROOT)) {
+ const rel = path.relative(SRC_ROOT, file).replaceAll("\\", "/");
+ if (ALLOWLIST.includes(rel)) continue;
+ const content = fs.readFileSync(file, "utf8");
+ if (content.toLowerCase().includes("ncryptsec")) {
+ violations.push(rel);
+ }
+ }
+ assert.deepEqual(
+ violations,
+ [],
+ `NIP-49 material outside allowlisted files — wire it through the ` +
+ `identity layer (and its egress-guarded Rust commands) instead:\n` +
+ violations.join("\n"),
+ );
+});
diff --git a/desktop/src/shared/styles/globals/components.css b/desktop/src/shared/styles/globals/components.css
index c21e4a617d..036cf925e0 100644
--- a/desktop/src/shared/styles/globals/components.css
+++ b/desktop/src/shared/styles/globals/components.css
@@ -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 {
diff --git a/desktop/src/shared/ui/alert-dialog.tsx b/desktop/src/shared/ui/alert-dialog.tsx
index d4bf93d4b1..97574abbe6 100644
--- a/desktop/src/shared/ui/alert-dialog.tsx
+++ b/desktop/src/shared/ui/alert-dialog.tsx
@@ -5,6 +5,11 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/shared/lib/cn";
import { buttonVariants } from "@/shared/ui/button";
+import {
+ type CardTextureSize,
+ type CardTextureTone,
+ texturedSurfaceClasses,
+} from "@/shared/ui/card";
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
import {
MODAL_CONTENT_MOTION_CLASS,
@@ -31,25 +36,59 @@ const AlertDialogOverlay = React.forwardRef<
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+type AlertDialogContentProps = React.ComponentPropsWithoutRef<
+ typeof AlertDialogPrimitive.Content
+> & {
+ surface?: "default" | "textured";
+ textureSize?: CardTextureSize;
+ textureTone?: CardTextureTone;
+};
+
const AlertDialogContent = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-
-