feat(desktop): gate sign-out behind key backup + typed confirmation (#2424)

Signed-off-by: Wes <wesb@block.xyz>
Co-authored-by: npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-07-22 15:38:52 -07:00
committed by GitHub
co-authored by npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c
parent c96ae0ea97
commit fd967c6ea4
7 changed files with 423 additions and 105 deletions
+1
View File
@@ -109,6 +109,7 @@ export default defineConfig({
"**/onboarding-agent-defaults.spec.ts",
"**/nostr-bind.spec.ts",
"**/profile-nsec-reveal.spec.ts",
"**/signout-confirmation.spec.ts",
"**/agent-provider-dropdowns.spec.ts",
"**/agent-lifecycle-feedback.spec.ts",
"**/inbox-live-update.spec.ts",
-9
View File
@@ -378,15 +378,6 @@ const overrides = new Map([
// observable (propagate real errors); verify_fully_wiped checks all three
// keychain shapes (main blob, DPK blob, per-key "identity"). +73 lines.
["src-tauri/src/secret_store.rs", 1307],
// sign-out wipe: Sign Out section (AlertDialog + controlled state) added
// at the bottom of the Profile settings page. Load-bearing UX feature;
// queued to split when ProfileSettingsCard is broken into sub-components.
// +20 lines: scroll-position save/restore across avatar editor open/close
// to prevent layout shift from the Sign Out section causing a viewport jump.
// +11 lines: signout-dev-webview-state — clear localStorage/sessionStorage
// on successful signOut() resolve so dev-build webview state doesn't survive
// a reset and vouch for the fresh key. Comment explains the race/redundancy.
["src/features/settings/ui/ProfileSettingsCard.tsx", 1044],
// keyring-dev-isolation: keyring_service() fn (7 lines) replaces the const
// to return "buzz-desktop-dev" in debug builds. Load-bearing isolation fix.
// +10 (1042 -> 1052): media_fetch_client with redirect::Policy::none() so a
@@ -7,6 +7,11 @@ type NsecMaskedDisplayProps = {
nsec: string;
/** "bare" drops the boxed chrome for the onboarding spotlight treatment. */
variant?: "boxed" | "bare";
/**
* Called when the user reveals or copies the key. Lets flows that require
* a backup (e.g. sign-out) gate on actual interaction with the key.
*/
onKeyInteraction?: () => void;
};
export const ONBOARDING_KEY_FRAME_CLASS =
@@ -25,6 +30,7 @@ export const ONBOARDING_KEY_TEXT_CLASS = "buzz-onboarding-key-text";
export function NsecMaskedDisplay({
nsec,
variant = "boxed",
onKeyInteraction,
}: NsecMaskedDisplayProps) {
const [isRevealed, setIsRevealed] = React.useState(false);
const [isCopied, setIsCopied] = React.useState(false);
@@ -40,11 +46,13 @@ export function NsecMaskedDisplay({
}, []);
function handleRevealToggle() {
if (!isRevealed) onKeyInteraction?.();
setIsRevealed((prev) => !prev);
}
async function handleCopy() {
await writeTextToClipboard(nsec);
onKeyInteraction?.();
setIsCopied(true);
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setIsCopied(false), 2000);
@@ -13,17 +13,7 @@ import {
useUpdateProfileMutation,
} from "@/features/profile/hooks";
import { NsecMaskedDisplay } from "@/features/onboarding/ui/NsecMaskedDisplay";
import { getNsec, signOut } from "@/shared/api/tauriIdentity";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { getNsec } from "@/shared/api/tauriIdentity";
import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
@@ -31,11 +21,11 @@ import {
parseEmojiAvatarDataUrl,
} from "@/features/profile/ui/ProfileAvatarEditor";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
import { Textarea } from "@/shared/ui/textarea";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import { SignOutSection } from "./SignOutSection";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
type ProfileSettingsCardProps = {
@@ -262,8 +252,6 @@ export function ProfileSettingsCard({
const [shouldRenderAvatarEditor, setShouldRenderAvatarEditor] =
React.useState(false);
const [avatarSquishKey, setAvatarSquishKey] = React.useState(0);
const [isSignOutOpen, setIsSignOutOpen] = React.useState(false);
const [isSignOutPending, setIsSignOutPending] = React.useState(false);
const displayNameInputRef = React.useRef<HTMLInputElement>(null);
const aboutTextareaRef = React.useRef<HTMLTextAreaElement>(null);
const sectionRef = React.useRef<HTMLElement>(null);
@@ -956,88 +944,7 @@ export function ProfileSettingsCard({
</div>
</div>
<div
className="mt-8 border-t border-border/60 pb-6 pt-5"
data-testid="settings-signout"
>
<div className="flex items-center justify-between gap-4 px-1">
<div className="min-w-0 space-y-1">
<h2 className="text-lg font-semibold tracking-tight">Sign out</h2>
<p className="text-sm text-muted-foreground">
Removes your identity key and all local app data from this device.
Back up your private key (nsec) first this cannot be undone.
</p>
</div>
<Button
className="shrink-0"
data-testid="signout-open-dialog"
disabled={isSignOutPending}
onClick={() => setIsSignOutOpen(true)}
type="button"
variant="destructive"
>
{isSignOutPending ? (
<Spinner aria-label="Signing out" className="h-4 w-4 border-2" />
) : null}
{isSignOutPending ? "Signing out…" : "Sign Out"}
</Button>
</div>
<AlertDialog
onOpenChange={(open) => {
if (!open && !isSignOutPending) setIsSignOutOpen(false);
}}
open={isSignOutOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Sign out and wipe all data?</AlertDialogTitle>
<AlertDialogDescription>
This will delete your identity key, all agent settings, and
cached data from this device, then relaunch Buzz into first-run
setup. Make sure you have your private key (nsec) backed up
before continuing this cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSignOutPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90"
data-testid="signout-confirm"
disabled={isSignOutPending}
onClick={() => {
setIsSignOutPending(true);
// Keep the pending state if signOut() resolves before restart.
signOut()
.then(() => {
// Clear web storage for this origin on the success path
// only. This covers dev builds where the Rust webview wipe
// targets the .app-bundle WebKit dir (missing in `tauri
// dev`), preventing stale community config from vouching
// for the fresh key on next boot. In production the Rust
// wipe already handles this; the clear here is redundant
// but harmless. The restart may race this clear — that is
// acceptable; Fix A (pubkey-scoped heuristic) is the
// correctness gate.
window.localStorage.clear();
window.sessionStorage.clear();
})
.catch((err: unknown) => {
setIsSignOutPending(false);
setIsSignOutOpen(false);
toast.error(
err instanceof Error ? err.message : "Sign out failed.",
);
});
}}
>
{isSignOutPending ? "Signing out…" : "Delete My Data"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<SignOutSection />
</section>
);
}
@@ -0,0 +1,267 @@
import * as React from "react";
import { toast } from "sonner";
import { NsecMaskedDisplay } from "@/features/onboarding/ui/NsecMaskedDisplay";
import { getNsec, signOut } from "@/shared/api/tauriIdentity";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
/**
* The exact phrase the user must type before the destructive sign-out button
* unlocks. Kept lowercase; the comparison trims and lowercases input so a
* stray capital or trailing space does not trip people up — the friction is
* deliberate typing, not case sensitivity.
*/
export const SIGNOUT_CONFIRM_PHRASE = "wipe all my data";
/**
* Sign-out card + destructive confirmation flow.
*
* 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.
* 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.
*/
export function SignOutSection() {
const [isOpen, setIsOpen] = React.useState(false);
const [isPending, setIsPending] = React.useState(false);
// Backup gate.
const [nsec, setNsec] = React.useState<string | null>(null);
const [nsecError, setNsecError] = React.useState<string | null>(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.
const fetchCancelledRef = React.useRef(false);
// Typed-confirmation gate.
const [confirmText, setConfirmText] = React.useState("");
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;
function resetDialogState() {
fetchCancelledRef.current = true;
setNsec(null);
setNsecError(null);
setIsNsecLoading(false);
setHasInteractedWithKey(false);
setHasConfirmedBackup(false);
setConfirmText("");
}
React.useEffect(() => {
return () => {
fetchCancelledRef.current = true;
setNsec(null);
};
}, []);
async function openDialog() {
setIsOpen(true);
fetchCancelledRef.current = false;
setIsNsecLoading(true);
setNsecError(null);
try {
const value = await getNsec();
if (!fetchCancelledRef.current) setNsec(value);
} catch (err) {
if (!fetchCancelledRef.current)
setNsecError(
err instanceof Error
? err.message
: "Failed to retrieve private key.",
);
} finally {
if (!fetchCancelledRef.current) setIsNsecLoading(false);
}
}
function handleSignOut() {
setIsPending(true);
// Keep the pending state if signOut() resolves before restart.
signOut()
.then(() => {
// Clear web storage for this origin on the success path only. This
// covers dev builds where the Rust webview wipe targets the
// .app-bundle WebKit dir (missing in `tauri dev`), preventing stale
// community config from vouching for the fresh key on next boot. In
// production the Rust wipe already handles this; the clear here is
// redundant but harmless. The restart may race this clear — that is
// acceptable; Fix A (pubkey-scoped heuristic) is the correctness
// gate.
window.localStorage.clear();
window.sessionStorage.clear();
})
.catch((err: unknown) => {
setIsPending(false);
setIsOpen(false);
resetDialogState();
toast.error(err instanceof Error ? err.message : "Sign out failed.");
});
}
return (
<div
className="mt-8 border-t border-border/60 pb-6 pt-5"
data-testid="settings-signout"
>
<div className="flex items-center justify-between gap-4 px-1">
<div className="min-w-0 space-y-1">
<h2 className="text-lg font-semibold tracking-tight">Sign out</h2>
<p className="text-sm text-muted-foreground">
Removes your identity key and all local app data from this device.
Back up your private key (nsec) first this cannot be undone.
</p>
</div>
<Button
className="shrink-0"
data-testid="signout-open-dialog"
disabled={isPending}
onClick={() => void openDialog()}
type="button"
variant="destructive"
>
{isPending ? (
<Spinner aria-label="Signing out" className="h-4 w-4 border-2" />
) : null}
{isPending ? "Signing out…" : "Sign Out"}
</Button>
</div>
<AlertDialog
onOpenChange={(open) => {
if (!open && !isPending) {
setIsOpen(false);
resetDialogState();
}
}}
open={isOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Sign out and wipe all data?</AlertDialogTitle>
<AlertDialogDescription>
This will delete your identity key, all agent settings, and cached
data from this device, then relaunch Buzz into first-run setup.
This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-3">
<p className="text-sm font-medium">
1. Back up your private key (nsec)
</p>
{isNsecLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : nsecError ? (
<p
className="text-sm text-destructive"
data-testid="signout-nsec-error"
>
{nsecError}
</p>
) : nsec ? (
<NsecMaskedDisplay
nsec={nsec}
onKeyInteraction={() => setHasInteractedWithKey(true)}
/>
) : null}
<label
className="flex cursor-pointer items-start gap-2.5 text-sm has-[button:disabled]:cursor-not-allowed has-[button:disabled]:opacity-60"
data-testid="signout-backup-confirm-label"
htmlFor="signout-backup-confirm"
>
<Checkbox
checked={hasConfirmedBackup}
className="mt-0.5"
data-testid="signout-backup-confirm"
disabled={!canConfirmBackup || isPending}
id="signout-backup-confirm"
onCheckedChange={(checked) =>
setHasConfirmedBackup(checked === true)
}
/>
<span>
I have saved my private key somewhere safe.
{!canConfirmBackup ? (
<span className="block text-xs text-muted-foreground">
Reveal or copy the key above first.
</span>
) : null}
</span>
</label>
</div>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="signout-confirm-phrase"
>
2. Type{" "}
<span className="font-semibold">"{SIGNOUT_CONFIRM_PHRASE}"</span>{" "}
to confirm
</label>
<Input
autoComplete="off"
data-testid="signout-confirm-phrase"
disabled={isPending}
id="signout-confirm-phrase"
onChange={(event) => setConfirmText(event.target.value)}
placeholder={SIGNOUT_CONFIRM_PHRASE}
spellCheck={false}
value={confirmText}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
{/* A plain Button, not AlertDialogAction: Radix's Action closes
the dialog on click, which would drop the pending state while
the wipe + restart is still in flight. */}
<Button
data-testid="signout-confirm"
disabled={!canDelete}
onClick={handleSignOut}
type="button"
variant="destructive"
>
{isPending ? (
<Spinner
aria-label="Signing out"
className="h-4 w-4 border-2"
/>
) : null}
{isPending ? "Signing out…" : "Delete My Data"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+5
View File
@@ -9243,6 +9243,11 @@ export function maybeInstallE2eTauriMocks() {
sig: "e2e-signed-nostr-binding",
});
}
case "sign_out":
// Production wipes local state and restarts the app. In the browser
// harness there is nothing to wipe; resolving is enough — specs
// assert invocation via __BUZZ_E2E_COMMANDS__ and the pending UI.
return;
case "get_nsec": {
const nsecSequence = activeConfig?.mock?.nsecErrors;
if (nsecSequence && nsecSequence.length > 0) {
@@ -0,0 +1,139 @@
/**
* E2E tests for the destructive sign-out confirmation flow.
*
* Signing out wipes the identity key and all local data, so the dialog gates
* "Delete My Data" behind two explicit steps:
* 1. backup — reveal/copy the nsec, then check "I have saved my private key"
* 2. typed confirmation — type the exact phrase "wipe all my data"
*/
import { expect, type Page, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
const CONFIRM_PHRASE = "wipe all my data";
// The mock bridge routes copy_text_to_clipboard through navigator.clipboard,
// which requires explicit permissions in headless Chromium.
test.use({ permissions: ["clipboard-read", "clipboard-write"] });
async function openSignOutDialog(page: Page) {
await openSettings(page, "profile");
const section = page.getByTestId("settings-signout");
await section.scrollIntoViewIfNeeded();
await page.getByTestId("signout-open-dialog").click();
await expect(page.getByRole("alertdialog")).toBeVisible({ timeout: 5_000 });
}
test("delete button unlocks only after backup + typed phrase", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await openSignOutDialog(page);
const deleteButton = page.getByTestId("signout-confirm");
const backupCheckbox = page.getByTestId("signout-backup-confirm");
const phraseInput = page.getByTestId("signout-confirm-phrase");
// Everything locked initially: no key interaction yet.
await expect(deleteButton).toBeDisabled();
await expect(backupCheckbox).toBeDisabled();
// Copying the key unlocks the backup checkbox.
await page.getByTestId("nsec-copy").click();
await expect(backupCheckbox).toBeEnabled();
await backupCheckbox.click();
// Backup alone is not enough.
await expect(deleteButton).toBeDisabled();
// Wrong phrase keeps it locked.
await phraseInput.fill("wipe my data");
await expect(deleteButton).toBeDisabled();
// Exact phrase (case/whitespace tolerant) unlocks it.
await phraseInput.fill(` ${CONFIRM_PHRASE.toUpperCase()} `);
await expect(deleteButton).toBeEnabled();
// Clearing the phrase locks it again.
await phraseInput.fill("");
await expect(deleteButton).toBeDisabled();
});
test("reveal also unlocks the backup checkbox", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await openSignOutDialog(page);
const backupCheckbox = page.getByTestId("signout-backup-confirm");
await expect(backupCheckbox).toBeDisabled();
await page.getByTestId("nsec-reveal-toggle").click();
await expect(backupCheckbox).toBeEnabled();
});
test("completing both gates invokes sign_out", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await openSignOutDialog(page);
await page.getByTestId("nsec-copy").click();
await page.getByTestId("signout-backup-confirm").click();
await page.getByTestId("signout-confirm-phrase").fill(CONFIRM_PHRASE);
const deleteButton = page.getByTestId("signout-confirm");
await expect(deleteButton).toBeEnabled();
await deleteButton.click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }
).__BUZZ_E2E_COMMANDS__?.includes("sign_out") ?? false,
),
)
.toBe(true);
});
test("cancel resets the gates for the next open", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await openSignOutDialog(page);
// Satisfy both gates, then cancel.
await page.getByTestId("nsec-copy").click();
await page.getByTestId("signout-backup-confirm").click();
await page.getByTestId("signout-confirm-phrase").fill(CONFIRM_PHRASE);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("alertdialog")).not.toBeVisible();
// Reopen — everything must be locked again.
await page.getByTestId("signout-open-dialog").click();
await expect(page.getByRole("alertdialog")).toBeVisible();
await expect(page.getByTestId("signout-backup-confirm")).toBeDisabled();
await expect(page.getByTestId("signout-confirm-phrase")).toHaveValue("");
await expect(page.getByTestId("signout-confirm")).toBeDisabled();
});
test("nsec load failure still allows sign-out (backup step degrades)", async ({
page,
}) => {
await installMockBridge(page, { nsecError: "Keychain locked" });
await page.goto("/");
await openSignOutDialog(page);
// Error shown in place of the key; checkbox is usable so the user is not
// permanently locked out of signing out.
await expect(page.getByTestId("signout-nsec-error")).toContainText(
"Keychain locked",
);
const backupCheckbox = page.getByTestId("signout-backup-confirm");
await expect(backupCheckbox).toBeEnabled();
await backupCheckbox.click();
await page.getByTestId("signout-confirm-phrase").fill(CONFIRM_PHRASE);
await expect(page.getByTestId("signout-confirm")).toBeEnabled();
});