mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1cl47vfhsqpqy9pwndphpm36vcp7vvz5h2js4qpqm5yewzj7nutkq7xyw8c
parent
c96ae0ea97
commit
fd967c6ea4
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user