feat(identity): add optional Keycase backup flow

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-07-28 12:46:30 -07:00
co-authored by Taylor Ho
parent 855d25ef3a
commit 8840d6c63d
12 changed files with 111 additions and 178 deletions
+1 -1
View File
@@ -278,7 +278,7 @@ pub async fn save_ncryptsec_copy(
let dest = match crate::commands::export_util::pick_save_path(
&app_handle,
crate::key_backup::BACKUP_FILE_NAME,
"Encrypted key backup",
"Keycase",
&["ncryptsec"],
)
.await?
+2 -3
View File
@@ -100,7 +100,7 @@ pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result<Keys, String> {
let encrypted = parse_ncryptsec(input)?;
let secret_key = encrypted
.decrypt(password)
.map_err(|_| "wrong passphrase or corrupted backup".to_string())?;
.map_err(|_| "wrong Keycase password or damaged Keycase".to_string())?;
Ok(Keys::new(secret_key))
}
@@ -119,8 +119,7 @@ pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result<Ke
.get(..NCRYPTSEC_HRP.len())
.is_some_and(|head| head.eq_ignore_ascii_case(NCRYPTSEC_HRP));
if hrp_match {
let password =
password.ok_or_else(|| "encrypted backup requires a passphrase".to_string())?;
let password = password.ok_or_else(|| "Keycase requires a password".to_string())?;
decrypt_ncryptsec(trimmed, password)
} else {
Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))
+4 -4
View File
@@ -41,7 +41,7 @@ fn wrong_password_is_a_friendly_error() {
let keys = Keys::generate();
let blob = create_backup_blob(&keys, "right password", FAST_LOG_N).unwrap();
let err = decrypt_ncryptsec(&blob, "wrong password").unwrap_err();
assert_eq!(err, "wrong passphrase or corrupted backup");
assert_eq!(err, "wrong Keycase password or damaged Keycase");
}
#[test]
@@ -90,13 +90,13 @@ fn recover_keys_ncryptsec_happy_path() {
#[test]
fn recover_keys_ncryptsec_requires_password() {
let err = recover_keys_from_input(SPEC_NCRYPTSEC, None).unwrap_err();
assert_eq!(err, "encrypted backup requires a passphrase");
assert_eq!(err, "Keycase requires a password");
}
#[test]
fn recover_keys_ncryptsec_wrong_password() {
let err = recover_keys_from_input(SPEC_NCRYPTSEC, Some("wrong")).unwrap_err();
assert_eq!(err, "wrong passphrase or corrupted backup");
assert_eq!(err, "wrong Keycase password or damaged Keycase");
}
/// Bech32 permits an all-uppercase encoding: `NCRYPTSEC1…` must classify as
@@ -108,7 +108,7 @@ fn recover_keys_uppercase_ncryptsec_classifies_as_encrypted() {
let upper = SPEC_NCRYPTSEC.to_ascii_uppercase();
// Routing proof: encrypted path demands a passphrase.
let err = recover_keys_from_input(&upper, None).unwrap_err();
assert_eq!(err, "encrypted backup requires a passphrase");
assert_eq!(err, "Keycase requires a password");
// With the passphrase, the bech32 decoder accepts the uppercase form.
let keys = recover_keys_from_input(&upper, Some("nostr")).unwrap();
assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX);
@@ -16,29 +16,9 @@ import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
export type BackupStepMode = "encrypted" | "raw";
/**
* Pure helper so the disabled logic can be unit-tested without a DOM.
*
* Encrypted mode (default): Next unlocks once the backup blob exists — the
* user must either create a backup or explicitly switch to the raw key.
* Raw mode: disabled while loading or after a failed load (only the explicit
* "Skip for now" ghost advances past an error), matching the previous flow.
*/
export function backupNextDisabled({
mode,
hasBackup,
isLoading,
loadError,
}: {
mode: BackupStepMode;
hasBackup: boolean;
isLoading: boolean;
loadError: string | null;
}): boolean {
if (mode === "encrypted") {
return !hasBackup;
}
return isLoading || loadError !== null;
/** Saving a Keycase is recommended, never required to continue onboarding. */
export function backupNextDisabled(): boolean {
return false;
}
type BackupStepProps = {
@@ -48,14 +28,11 @@ type BackupStepProps = {
};
/**
* Onboarding backup step — encrypted by default. The user protects their
* freshly created key with a passphrase and gets a NIP-49 `ncryptsec1…`
* backup; the raw key is only fetched (and shown) after an explicit
* "Show raw key instead" click. The default path never invokes `get_nsec`.
* Onboarding backup step — recommends a portable Keycase without blocking
* setup. The raw key is fetched only after the user chooses the advanced path.
*/
export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
const [mode, setMode] = React.useState<BackupStepMode>("encrypted");
const [hasBackup, setHasBackup] = React.useState(false);
const [nsec, setNsec] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [loadError, setLoadError] = React.useState<string | null>(null);
@@ -103,11 +80,11 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
>
<div className="flex w-full max-w-[500px] shrink-0 flex-col text-center">
<h1 className="text-title font-normal text-foreground">
Your unique identity key has been created
Account created!
</h1>
<p className="mt-5 text-sm leading-6 text-foreground/80">
{mode === "encrypted"
? "Protect it with a passphrase and keep an encrypted backup in case you ever need to restore your account."
? "Buzz keeps your identity in the system keychain. Save a portable, password-protected Keycase in case you need to restore it elsewhere."
: "This key is stored in your system keychain, but save it some place safe in case you ever need to restore your account."}
</p>
</div>
@@ -116,10 +93,14 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
{mode === "encrypted" ? (
<Card className="w-full px-8 py-6" variant="textured">
<div className="mx-auto w-full max-w-[832px]">
<EncryptedBackupCreator
onCreated={() => setHasBackup(true)}
variant="spotlight"
/>
<div className="mb-5 space-y-2 text-center">
<h2 className="text-lg font-medium">Save a Keycase</h2>
<p className="text-xs leading-5 text-muted-foreground">
A Keycase is still private. Never publish or share it. You
need both the file and password to restore your identity.
</p>
</div>
<EncryptedBackupCreator variant="spotlight" />
</div>
</Card>
) : isLoading ? (
@@ -164,7 +145,7 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
</p>
)}
{mode === "encrypted" && !hasBackup ? (
{mode === "encrypted" ? (
<div className="mt-4 flex justify-center">
<Button
className="h-8 text-sm text-muted-foreground hover:text-accent-foreground"
@@ -174,7 +155,7 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
type="button"
variant="ghost"
>
Show raw key instead
Advanced: show my private key instead
</Button>
</div>
) : null}
@@ -194,12 +175,7 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-next"
disabled={backupNextDisabled({
mode,
hasBackup,
isLoading,
loadError,
})}
disabled={backupNextDisabled()}
onClick={onNext}
type="button"
>
@@ -23,22 +23,18 @@ import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
type EncryptedBackupCreatorProps = {
/** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */
variant?: "spotlight" | "boxed";
/** Fired once the backup blob exists (hosts gate Next / show toasts). */
onCreated?: (ncryptsec: string) => void;
/** Fired only after the portable Keycase has been saved successfully. */
onSaved?: (path: string) => void;
};
/**
* Passphrase-first NIP-49 backup creation flow, shared by the onboarding
* BackupStep and the settings Password Backup row.
*
* The raw private key never enters this component: it collects a passphrase,
* asks Rust to create + persist the encrypted backup, and displays the
* returned `ncryptsec1…` blob. A generated 6-word passphrase is the default;
* "choose my own" requires ≥12 characters plus confirmation.
* Password-first Keycase creation flow shared by onboarding and Settings.
* The raw private key never enters this component. Rust creates the NIP-49
* payload locally, then the native save dialog produces the user-owned file.
*/
export function EncryptedBackupCreator({
variant = "spotlight",
onCreated,
onSaved,
}: EncryptedBackupCreatorProps) {
const [state, dispatch] = React.useReducer(
encryptedBackupReducer,
@@ -61,7 +57,7 @@ export function EncryptedBackupCreator({
message:
err instanceof Error
? err.message
: "Failed to generate a passphrase.",
: "Failed to generate a Keycase password.",
});
}
}, []);
@@ -82,16 +78,31 @@ export function EncryptedBackupCreator({
const ncryptsec = await createNcryptsecBackup(passphrase);
if (!mountedRef.current) return;
dispatch({ type: "create-succeeded", ncryptsec });
onCreated?.(ncryptsec);
setIsSaving(true);
setSaveError(null);
try {
const path = await saveNcryptsecCopy(ncryptsec);
if (mountedRef.current && path) {
setSavedPath(path);
onSaved?.(path);
}
} catch (err) {
if (mountedRef.current)
setSaveError(
err instanceof Error ? err.message : "Failed to save Keycase.",
);
} finally {
if (mountedRef.current) setIsSaving(false);
}
} catch (err) {
if (mountedRef.current)
dispatch({
type: "create-failed",
message:
err instanceof Error ? err.message : "Failed to create backup.",
err instanceof Error ? err.message : "Failed to create Keycase.",
});
}
}, [onCreated, state]);
}, [onSaved, state]);
const handleSaveCopy = React.useCallback(async () => {
if (!state.ncryptsec || isSaving) return;
@@ -99,16 +110,19 @@ export function EncryptedBackupCreator({
setSaveError(null);
try {
const path = await saveNcryptsecCopy(state.ncryptsec);
if (mountedRef.current && path) setSavedPath(path);
if (mountedRef.current && path) {
setSavedPath(path);
onSaved?.(path);
}
} catch (err) {
if (mountedRef.current)
setSaveError(
err instanceof Error ? err.message : "Failed to save a copy.",
err instanceof Error ? err.message : "Failed to save Keycase.",
);
} finally {
if (mountedRef.current) setIsSaving(false);
}
}, [isSaving, state.ncryptsec]);
}, [isSaving, onSaved, state.ncryptsec]);
const isSpotlight = variant === "spotlight";
const customIssue = customPassphraseIssue(
@@ -135,14 +149,14 @@ export function EncryptedBackupCreator({
variant="outline"
>
{isSaving ? <Spinner className="h-3.5 w-3.5 border-2" /> : null}
Save a copy…
Save Keycase…
</Button>
{savedPath ? (
<p
className="text-xs text-muted-foreground"
data-testid="encrypted-backup-saved-path"
>
Saved to {savedPath}
Keycase saved to {savedPath}
</p>
) : null}
</div>
@@ -150,8 +164,8 @@ export function EncryptedBackupCreator({
<p className="text-center text-sm text-destructive">{saveError}</p>
) : null}
<p className="text-center text-xs leading-5 text-muted-foreground">
This backup can only be unlocked with your passphrase. Without the
passphrase it cannot be recovered — not even by Buzz.
Keep this Keycase private. You need both the file and its password to
restore your identity. Buzz cannot reset the password.
</p>
</div>
);
@@ -180,13 +194,13 @@ export function EncryptedBackupCreator({
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Could not generate a passphrase: {state.generateError}
Could not generate a Keycase password: {state.generateError}
</span>
</div>
) : (
<div className="flex items-center justify-center gap-2 py-4 text-sm text-foreground/70">
<Spinner className="h-4 w-4 border-2" />
Generating a passphrase…
Generating a password…
</div>
)}
<div className="flex items-center justify-center gap-3">
@@ -199,7 +213,7 @@ export function EncryptedBackupCreator({
variant="outline"
>
<RefreshCw className="h-3.5 w-3.5" />
New passphrase
New generated password
</Button>
<Button
className="h-8 text-sm text-muted-foreground hover:text-accent-foreground"
@@ -213,15 +227,15 @@ export function EncryptedBackupCreator({
</Button>
</div>
<p className="text-center text-xs leading-5 text-muted-foreground">
Write this passphrase down. It protects your backup and cannot be
recovered if lost.
Save this generated passphrase as your Keycase password. Store it
separately from the private Keycase file.
</p>
</div>
) : (
<div className="space-y-3">
<div className="space-y-2">
<Input
aria-label="Backup passphrase"
aria-label="Keycase password"
autoComplete="new-password"
className="h-10 bg-background"
data-testid="backup-passphrase-custom"
@@ -231,12 +245,12 @@ export function EncryptedBackupCreator({
value: event.target.value,
})
}
placeholder={`Passphrase (min ${MIN_CUSTOM_PASSPHRASE_LEN} characters)`}
placeholder={`Password (min ${MIN_CUSTOM_PASSPHRASE_LEN} characters)`}
type="password"
value={state.customPassphrase}
/>
<Input
aria-label="Confirm backup passphrase"
aria-label="Confirm Keycase password"
autoComplete="new-password"
className="h-10 bg-background"
data-testid="backup-passphrase-confirm"
@@ -246,7 +260,7 @@ export function EncryptedBackupCreator({
value: event.target.value,
})
}
placeholder="Confirm passphrase"
placeholder="Confirm password"
type="password"
value={state.customConfirm}
/>
@@ -268,11 +282,11 @@ export function EncryptedBackupCreator({
type="button"
variant="ghost"
>
Use a generated passphrase
Use a generated password
</Button>
</div>
<p className="text-center text-xs leading-5 text-muted-foreground">
Your passphrase protects the backup and cannot be recovered if lost.
Your password protects the Keycase. Buzz cannot reset it if lost.
</p>
</div>
)}
@@ -297,10 +311,10 @@ export function EncryptedBackupCreator({
{state.isCreating ? (
<>
<Spinner className="h-4 w-4 border-2" />
Encrypting… this takes a couple of seconds
Creating Keycase… this takes a couple of seconds
</>
) : (
"Create encrypted backup"
"Create Keycase"
)}
</Button>
</div>
@@ -96,7 +96,7 @@ export function NostrKeyImportForm({
if (file.size > NOSTR_KEY_FILE_MAX_BYTES) {
setImportError(
"That file is too large to be a key. Choose a .key or .ncryptsec backup file, or paste your key.",
"That file is too large to be a Keycase or private key. Choose another file.",
);
return;
}
@@ -126,7 +126,7 @@ export function NostrKeyImportForm({
if (!isValid) {
setImportError(
isEncryptedInput
? "Enter the passphrase for this encrypted backup."
? "Enter the password for this Keycase."
: "That doesn't look like a valid nsec. Paste an nsec1 key.",
);
return;
@@ -244,7 +244,7 @@ export function NostrKeyImportForm({
</div>
{/* Hidden file input shared by both variants: the default drop zone and
the spotlight "Import from a file" button both open it. Accepts the
the spotlight "Use a Keycase" button both open it. Accepts the
.ncryptsec archives our own save flow emits alongside raw .key files. */}
<input
accept=".key,.ncryptsec,text/plain"
@@ -273,7 +273,7 @@ export function NostrKeyImportForm({
type="button"
variant="ghost"
>
Import from a file
Use a Keycase
</Button>
</div>
) : (
@@ -358,7 +358,7 @@ export function NostrKeyImportForm({
className="text-sm font-medium text-foreground"
htmlFor="nostr-import-passphrase"
>
Backup passphrase
Keycase password
</label>
<Input
autoComplete="off"
@@ -370,11 +370,14 @@ export function NostrKeyImportForm({
setPassphrase(event.target.value);
setImportError(null);
}}
placeholder="Passphrase"
placeholder="Password"
spellCheck={false}
type="password"
value={passphrase}
/>
<p className="text-xs leading-5 text-muted-foreground">
Your Keycase and password stay on this device.
</p>
</div>
) : null}
@@ -393,7 +396,7 @@ export function NostrKeyImportForm({
data-testid="nostr-import-encrypted-badge"
>
<KeyRound aria-hidden="true" className="h-4 w-4 shrink-0" />
Encrypted key backup — enter its passphrase to import
Keycase · Private — enter its password to restore
</p>
) : previewNpub ? (
variant === "spotlight" ? (
@@ -9,8 +9,8 @@ type NsecMaskedDisplayProps = {
variant?: "boxed" | "bare";
/**
* What kind of secret is displayed. Drives labels, aria and testids:
* a raw private key ("nsec", default) can impersonate its holder; an
* encrypted backup ("ncryptsec") is only as sensitive as its passphrase.
* a raw private key ("nsec", default) can impersonate its holder; a
* Keycase ("ncryptsec") is only as sensitive as its passphrase.
*/
kind?: "nsec" | "ncryptsec";
/**
@@ -26,7 +26,7 @@ const KIND_LABELS = {
testIdPrefix: "nsec",
},
ncryptsec: {
noun: "encrypted backup",
noun: "Keycase",
testIdPrefix: "ncryptsec",
},
} as const;
@@ -53,74 +53,11 @@ test("currentStep_falls_back_to_1_for_pages_outside_the_step_list", () => {
});
// ---------------------------------------------------------------------------
// BackupStep gating: backupNextDisabled() pure helper
// BackupStep gating: saving a Keycase is recommended, not required
// ---------------------------------------------------------------------------
test("backup_next_disabled_in_encrypted_mode_until_backup_exists", () => {
// Encrypted (default) mode: the user must create a backup — or explicitly
// switch to the raw key — before Next unlocks. Loading/error state belongs
// to the raw path and must not leak into the encrypted gate.
assert.equal(
backupNextDisabled({
mode: "encrypted",
hasBackup: false,
isLoading: false,
loadError: null,
}),
true,
);
});
test("backup_next_enabled_in_encrypted_mode_once_backup_created", () => {
assert.equal(
backupNextDisabled({
mode: "encrypted",
hasBackup: true,
isLoading: false,
loadError: null,
}),
false,
);
});
test("backup_next_disabled_while_loading_raw_key", () => {
// During a slow keychain read, Next must be blocked — user cannot race past
// the key display before it is shown.
assert.equal(
backupNextDisabled({
mode: "raw",
hasBackup: false,
isLoading: true,
loadError: null,
}),
true,
);
});
test("backup_next_disabled_on_raw_load_error", () => {
// Error state: only the explicit "Skip for now" ghost advances; Next blocked.
assert.equal(
backupNextDisabled({
mode: "raw",
hasBackup: false,
isLoading: false,
loadError: "IPC error",
}),
true,
);
});
test("backup_next_enabled_after_clean_raw_load", () => {
// Key shown (or backend cleanly returned none) — user may proceed.
assert.equal(
backupNextDisabled({
mode: "raw",
hasBackup: false,
isLoading: false,
loadError: null,
}),
false,
);
test("backup_next_is_always_enabled", () => {
assert.equal(backupNextDisabled(), false);
});
// ---------------------------------------------------------------------------
@@ -178,7 +178,7 @@ function NsecRevealRow() {
}
/**
* Collapsible row for creating an encrypted NIP-49 backup on demand. The raw
* Collapsible row for saving a portable Keycase on demand. The raw
* private key never reaches this flow — the passphrase goes to Rust, which
* returns the persisted `ncryptsec1…` blob.
*/
@@ -189,9 +189,10 @@ function EncryptedBackupRow() {
<div className="px-4 py-3" data-testid="profile-encrypted-backup-row">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium">Password Backup</p>
<p className="text-sm font-medium">Keycase</p>
<p className="text-sm text-muted-foreground">
Protect your key with a password and save a recoverable backup.
Save a portable, password-protected copy of your identity. Keep it
private.
</p>
</div>
<button
@@ -201,12 +202,16 @@ function EncryptedBackupRow() {
onClick={() => setIsOpen((open) => !open)}
type="button"
>
{isOpen ? "Close" : "Create"}
{isOpen ? "Close" : "Save a new Keycase"}
</button>
</div>
{isOpen ? (
<div className="mt-3">
<EncryptedBackupCreator variant="boxed" />
<p className="mt-3 text-xs leading-5 text-muted-foreground">
Buzz cannot reset the password. Saving a new Keycase does not
invalidate copies you saved before.
</p>
</div>
) : null}
</div>
+2 -2
View File
@@ -9608,7 +9608,7 @@ export function maybeInstallE2eTauriMocks() {
case "save_ncryptsec_copy": {
const blob = (payload as { ncryptsec?: string } | null)?.ncryptsec;
if (!blob?.startsWith("ncryptsec1")) {
throw new Error("Not a valid encrypted key backup.");
throw new Error("Not a valid Keycase.");
}
// Production opens a native save dialog; the harness pretends the
// user picked a path.
@@ -9644,7 +9644,7 @@ export function maybeInstallE2eTauriMocks() {
input.trim() !== MOCK_NCRYPTSEC ||
request?.password !== MOCK_BACKUP_PASSPHRASE
) {
throw new Error("Wrong passphrase or corrupted backup.");
throw new Error("Wrong Keycase password or damaged Keycase.");
}
mockIdentityLostCleared = true;
mockIdentityLockedCleared = true;
+10 -11
View File
@@ -21,31 +21,31 @@ test("backup step appears on fresh-key path after profile submit", async ({
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
name: "Account created!",
}),
).toBeVisible();
});
// ---------------------------------------------------------------------------
// Encrypted-by-default path (plan D3): passphrase → create → ncryptsec shown.
// Keycase path: password → create locally → native save → saved confirmation.
// The raw key must never be fetched on this path.
// ---------------------------------------------------------------------------
test("encrypted backup happy path: generated passphrase, create, save copy, Next", async ({
test("Keycase happy path: generated password, create, native save, Next", async ({
page,
}) => {
await enterMachineBackup(page);
// Default mode: generated passphrase shown, Next locked until backup exists.
// Default mode: generated password shown, but backup remains optional.
await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/02-backup-step-passphrase.png` });
await page.getByTestId("encrypted-backup-create").click();
// The persisted blob is displayed masked; copy + save-a-copy available.
// The locally created blob stays masked; the portable save action is explicit.
const blob = page.getByTestId("ncryptsec-value");
await expect(blob).toBeVisible();
await expect(blob).toHaveCSS("filter", /blur/);
@@ -55,7 +55,6 @@ test("encrypted backup happy path: generated passphrase, create, save copy, Next
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/03-backup-step-encrypted.png` });
await page.getByTestId("encrypted-backup-save-copy").click();
await expect(page.getByTestId("encrypted-backup-saved-path")).toContainText(
"identity.ncryptsec",
);
@@ -98,7 +97,7 @@ test("custom passphrase requires 12 characters and confirmation", async ({
});
// ---------------------------------------------------------------------------
// Raw-key path: preserved behind an explicit "Show raw key instead" click.
// Raw-key path: preserved behind one explicit advanced action.
// ---------------------------------------------------------------------------
test("raw key path is one explicit click away and shows the masked nsec", async ({
@@ -124,7 +123,7 @@ test("raw key path is one explicit click away and shows the masked nsec", async
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-backup-step-raw-revealed.png` });
// Raw mode keeps the previous gating: key shown → Next enabled.
// Next remains enabled on the advanced raw-key path.
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
@@ -168,8 +167,8 @@ test("raw path shows error banner and retry button when get_nsec fails", async (
await expect(page.getByTestId("backup-load-error")).toBeVisible();
await expect(page.getByTestId("backup-retry")).toBeVisible();
// Next is blocked on error; Skip for now ghost is shown instead.
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
// Keychain failure does not trap the user; both Next and explicit skip work.
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await expect(page.getByTestId("backup-skip")).toBeVisible();
// Skip for now still advances to machine setup.
+1 -1
View File
@@ -645,7 +645,7 @@ test("first-launch encrypted backup import asks for a passphrase and continues",
await page.getByTestId("nostr-import-passphrase").fill("wrong passphrase");
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("nostr-import-feedback")).toContainText(
/wrong passphrase/i,
/wrong Keycase password/i,
);
await page