From b4e66e5944b1b16ba0e1b28baa12eb996a65fa02 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 29 Oct 2026 00:19:04 +1100 Subject: [PATCH] =?UTF-8?q?feat(settings):=20read-only=20Key=20backup=20ro?= =?UTF-8?q?w=20in=20Settings=20=E2=86=92=20Identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First run leaves the automatic backup choice on after a failed backup, so a later Builderlab sign-in may finish the backup in the background; this row makes that visible. `BackupRow` (`src/app/IdentitySettings.tsx`) reads `identity.backup` through `useSyncExternalStore`, as Settings reads its other stores, and sits in the existing rows `SettingsGroup` above "Sign out of Buzz" as a `PreferenceRow` with no trailing control; the sentence is wrapped in `role="status"`. One sentence per status — idle: "Buzz hasn’t checked your key’s backup since it opened." (what a launch or reload reports, not "no backup"); running: "Backing up your key…"; complete: "Your key is backed up to Builderlab."; waiting{no_consent}: "Automatic backup is off for this key."; waiting{no_session}: "Sign in to Builderlab to back up your key."; waiting{not_linked} and every failed kind: the entry from `BACKUP_FAILURES` (`backup-failure.ts`), so it is the same copy as first run's failed page, with the Settings tail "Buzz tries again when you next sign in to Builderlab." for the causes a new sign-in clears (busy, unreachable, unconfirmed, storage, signed_out). There is no second table. Read-only: no consent toggle, no retry. Hidden unless backup is offered, by the same rule first run uses: `backupOfferedFor(backup)` (`builderlab-account.ts`) answers false unless a Builderlab sign-in would be at the build's own portal — Settings passes no origin because its sign-in always is — and then returns the status's `builderlab`; any error answers false. Known differences, documented: the row appears after the first status read, so "Sign out of Buzz" moves down one row once the IPC answers; retryable messages end in "Try again." though the row has no button; under the `BUZZ_DEV_FIRST_RUN_ACCOUNT` seed first run offers backup while the row follows the real rule. Tests: `IdentitySettings.test.tsx` drives the real `createBackupStatus` store over a scripted native side with a mocked Tauri `Channel`: the sentence for complete, running, waiting{no_session}, waiting{not_linked}, failed{unreachable} and the signed_out tail; hidden when the status says `builderlab: false` and when the build has no Builderlab portal; following pushes at seq 2, 3 and 4 with a late seq-2 push changing nothing; no button or switch in the row. `Settings.test.tsx`'s identity case gains an inert `backup` store. docs/identity.md describes the row. Stack: PR 08 of the onboarding stack, on 07 to keep the stack linear, but it depends only on 03 (`backup.ts`, `backup-failure.ts`, `identity.backup`) and 05 (`backupOfferedFor`) — it can be reviewed before 06/07. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- docs/identity.md | 10 ++ src/app/IdentitySettings.test.tsx | 134 +++++++++++++++++++++++- src/app/IdentitySettings.tsx | 56 +++++++++- src/app/Settings.test.tsx | 10 +- src/features/identity/backup-failure.ts | 30 +++--- 5 files changed, 223 insertions(+), 17 deletions(-) diff --git a/docs/identity.md b/docs/identity.md index 978ab4ea8d..9ab48fcc98 100644 --- a/docs/identity.md +++ b/docs/identity.md @@ -527,6 +527,16 @@ every build without backup configured, the step fails at once as *Key backup isn’t available in this version of Buzz.* without saving the choice, and the user continues without backup. +**Settings → Identity** shows the uploader's latest status as a read-only **Key +backup** row, by the same rule: the row appears only when this build's uploader +can back up to Builderlab and a Builderlab sign-in would be at the build's own +portal, as the Settings one is. It uses first run's failure wording, except +that a failure first run's Try again could clear, and a lapsed sign-in, say +instead that Buzz tries again the next time the user signs in to Builderlab, +since a new sign-in hands the uploader a session and starts another pass. Idle is what a fresh launch or +a window reload reports, not evidence that no backup exists. The row has no +consent or retry control. + When secure storage confirms absence, native recovery commands support explicit browser sign-in. The session and recovered keys remain native; the renderer sees only validated public identities and attempt-scoped handles. Recovery prefers the diff --git a/src/app/IdentitySettings.test.tsx b/src/app/IdentitySettings.test.tsx index 12fdb52341..86bf2de1dd 100644 --- a/src/app/IdentitySettings.test.tsx +++ b/src/app/IdentitySettings.test.tsx @@ -1,17 +1,72 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; import { npubEncode } from "nostr-tools/nip19"; import { ToastProvider } from "../shared/design-system/ui/Toast"; +import { + type BackupStatus, + createBackupStatus, +} from "../features/identity/backup"; +import { BACKUP_FAILURES } from "../features/identity/backup-failure"; +import { backupOfferedFor } from "../features/identity/builderlab-account"; import type { Identity } from "../features/identity/service"; import { IdentitySettings } from "./IdentitySettings"; + +vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ + ...(await importOriginal()), + Channel: class { + constructor(public onmessage: (status: unknown) => void) {} + }, +})); + const viewer = "ab".repeat(32); afterEach(() => { cleanup(); vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); + +type Send = NonNullable[0]>; +type Progress = S extends unknown + ? Omit + : never; +const at = (seq: number, progress: Progress, builderlab = true) => + ({ seq, pass: seq, builderlab, ...progress }) as BackupStatus; + +/** The real status store over a native side that answers `initial` and records its watcher. */ +function backupHost(initial: BackupStatus) { + const watchers: { onmessage: (status: BackupStatus) => void }[] = []; + const send = vi.fn( + async ( + command: string, + args?: Record, + ): Promise => { + if (command === "nsec_backup_watch") { + watchers.push(args?.onEvent as (typeof watchers)[number]); + return undefined; + } + if (command === "nsec_backup_state") return initial; + throw new Error(`Unexpected command: ${command}`); + }, + ); + const store = createBackupStatus(send as Send); + return { + identity: { exportKey: vi.fn(), backup: store } as unknown as Identity, + push: (status: BackupStatus) => + act(() => watchers.at(-1)?.onmessage(status)), + /** Settles once the row's own offer check has: it started first on the same store. */ + settled: () => act(() => backupOfferedFor(store)), + }; +} + +const renderBackup = (initial: BackupStatus) => { + const host = backupHost(initial); + render(); + return host; +}; +const backupRow = () => screen.queryByText("Key backup"); it("shows exact public identity formats and copies either value", async () => { const writeText = vi.fn(async () => {}); const user = userEvent.setup(); @@ -91,7 +146,10 @@ it("keeps the identity selectable and explains manual recovery when copy fails", it("clears a revealed key when leaving identity settings and starts masked on return", async () => { const exportKey = vi.fn().mockResolvedValue("nsec-public-test-fixture"); - const identity = { exportKey } as unknown as Identity; + const identity = { + exportKey, + backup: backupHost(at(1, { status: "idle" })).identity.backup, + } as unknown as Identity; const user = userEvent.setup(); const view = render(); expect(exportKey).not.toHaveBeenCalled(); @@ -107,3 +165,75 @@ it("clears a revealed key when leaving identity settings and starts masked on re ); expect(exportKey).toHaveBeenCalledTimes(1); }); + +it.each([ + [{ status: "complete" }, "Your key is backed up to Builderlab."], + [{ status: "running" }, "Backing up your key…"], + [ + { status: "waiting", reason: "no_session" }, + "Sign in to Builderlab to back up your key.", + ], + [ + { status: "waiting", reason: "not_linked" }, + BACKUP_FAILURES.not_linked.message, + ], + // First run offers no Try again for it, but a new sign-in clears it. + [ + { status: "failed", kind: "signed_out" }, + "Your Builderlab sign-in is no longer valid, so your key wasn’t backed up. Buzz tries again when you next sign in to Builderlab.", + ], + // Settings has no Try again, so a retryable failure names what will retry. + ...(["busy", "unreachable", "unconfirmed", "storage"] as const).map( + (kind) => + [ + { status: "failed", kind }, + `${BACKUP_FAILURES[kind].cause} Buzz tries again when you next sign in to Builderlab.`, + ] as const, + ), +] as const)( + "reads the uploader's %o status as one sentence", + async (progress, sentence) => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + await renderBackup(at(1, progress)).settled(); + expect(backupRow()).toBeVisible(); + expect(screen.getByRole("status").textContent).toBe(sentence); + }, +); + +it("hides the backup row wherever first run would not offer backup", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + // This build's uploader has no Builderlab source. + await renderBackup(at(1, { status: "complete" }, false)).settled(); + expect(backupRow()).toBeNull(); + cleanup(); + // A build with no portal of its own never hands a credential over. + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", ""); + await renderBackup(at(1, { status: "complete" })).settled(); + expect(backupRow()).toBeNull(); + // The public identity is unaffected. + expect(screen.getByText(viewer, { exact: true })).toBeVisible(); +}); + +it("follows the status store as it publishes a higher seq", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + const host = renderBackup(at(1, { status: "idle" })); + await host.settled(); + const status = screen.getByRole("status"); + expect(status).toHaveTextContent( + "Buzz hasn’t checked your key’s backup since it opened.", + ); + host.push(at(2, { status: "running" })); + expect(status).toHaveTextContent("Backing up your key…"); + host.push(at(3, { status: "failed", kind: "busy" })); + const busy = /^Another backup of this key is in progress\. Buzz tries/; + expect(status).toHaveTextContent(busy); + // A late, lower seq is not newer news. + host.push(at(2, { status: "complete" })); + expect(status).toHaveTextContent(busy); + host.push(at(4, { status: "complete" })); + expect(status).toHaveTextContent("Your key is backed up to Builderlab."); + // Read-only: no consent, retry or other control beside the status. + const row = status.closest(".buzz-preference-row") as HTMLElement; + expect(within(row).queryByRole("button")).toBeNull(); + expect(within(row).queryByRole("switch")).toBeNull(); +}); diff --git a/src/app/IdentitySettings.tsx b/src/app/IdentitySettings.tsx index 8bc7a45573..61bae2e8d2 100644 --- a/src/app/IdentitySettings.tsx +++ b/src/app/IdentitySettings.tsx @@ -1,5 +1,8 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { npubEncode } from "nostr-tools/nip19"; +import type { BackupStatus } from "../features/identity/backup"; +import { BACKUP_FAILURES } from "../features/identity/backup-failure"; +import { backupOfferedFor } from "../features/identity/builderlab-account"; import type { Identity } from "../features/identity/service"; import { PrivateKey } from "../features/identity/PrivateKey"; import { SignOutDialog } from "../features/identity/SignOut"; @@ -13,6 +16,56 @@ import { ToastNotice } from "../shared/design-system/ui/Toast"; import { CopyIcon } from "../shared/design-system/icons"; import styles from "./IdentitySettings.module.css"; +const WAITING = { + no_consent: "Automatic backup is off for this key.", + no_session: "Sign in to Builderlab to back up your key.", + not_linked: BACKUP_FAILURES.not_linked.message, +} as const; + +/** One sentence per uploader status; failures read as first run's copy. */ +function backupSentence(status: BackupStatus) { + switch (status.status) { + case "idle": + // Nothing has run this launch; a backup may still exist from an earlier one. + return "Buzz hasn’t checked your key’s backup since it opened."; + case "running": + return "Backing up your key…"; + case "complete": + return "Your key is backed up to Builderlab."; + case "waiting": + return WAITING[status.reason]; + case "failed": { + // Settings has no Try again; a new Builderlab sign-in hands the + // uploader a session, which starts another pass, so a kind it clears + // (every retryable one, and a lapsed sign-in) says so. + const failure: { cause?: string; message: string } = + BACKUP_FAILURES[status.kind]; + return failure.cause + ? `${failure.cause} Buzz tries again when you next sign in to Builderlab.` + : failure.message; + } + } +} + +/** + * Read-only until the consent wording is signed off. Hidden wherever first run + * would not offer backup, by the same rule. + */ +function BackupRow({ backup }: { backup: Identity["backup"] }) { + const status = useSyncExternalStore(backup.subscribe, backup.snapshot); + const [offered, setOffered] = useState(false); + useEffect(() => { + void backupOfferedFor(backup).then(setOffered); + }, [backup]); + if (!offered || !status) return null; + return ( + {backupSentence(status)}} + /> + ); +} + export function IdentitySettings({ viewer, identity, @@ -93,6 +146,7 @@ export function IdentitySettings({ {!signingOut && } + [0]["identity"] + { + exportKey, + // No backup status: the row stays hidden. + backup: { + subscribe: () => () => {}, + snapshot: () => undefined, + ready: () => new Promise(() => {}), + }, + } as unknown as Parameters[0]["identity"] } /> , diff --git a/src/features/identity/backup-failure.ts b/src/features/identity/backup-failure.ts index 995119d7cf..d15ded0908 100644 --- a/src/features/identity/backup-failure.ts +++ b/src/features/identity/backup-failure.ts @@ -1,14 +1,23 @@ -/** A failure Try again may clear, and what first run's failed page says to do. */ -const retry = (cause: string, then = "Try again.") => - ({ message: `${cause} ${then}`, retryable: true }) as const; +/** + * A failure Try again may clear: its `cause`, which Settings shows alone (it + * has no Try again), then what first run's failed page says to do. + */ +const retry = (cause: Cause, then = "Try again.") => + ({ cause, message: `${cause} ${then}`, retryable: true }) as const; + +const SIGNED_OUT = + "Your Builderlab sign-in is no longer valid, so your key wasn’t backed up."; /** * Why "Backing up your key…" did not complete: the copy the failed page shows * and whether Try again can help. Native code names its own kinds and retries * them all on its next wake; `unavailable`, `interrupted`, `timeout` and - * `unknown` are the renderer's. No message carries a service or IPC answer. - * A leaf module: vite.config.ts validates the development seed against it - * through Node's native loader. + * `unknown` are the renderer's. A `cause` is what Settings shows before saying + * a later Builderlab sign-in tries again: every retryable kind's, and + * `signed_out`'s, which a new sign-in clears though first run's Try again, + * sending the same credential, cannot. No message carries a service or IPC + * answer. A leaf module: vite.config.ts validates the development seed + * against it through Node's native loader. */ export const BACKUP_FAILURES = { unavailable: { @@ -21,12 +30,7 @@ export const BACKUP_FAILURES = { ), interrupted: retry("The backup was interrupted."), timeout: retry("Builderlab is taking too long to confirm your backup."), - // A new sign-in clears this, though Try again, sending the same credential, cannot. - signed_out: { - message: - "Your Builderlab sign-in is no longer valid, so your key wasn’t backed up.", - retryable: false, - }, + signed_out: { cause: SIGNED_OUT, message: SIGNED_OUT, retryable: false }, not_offered: { message: "Builderlab doesn’t offer key backup for this account yet.", retryable: false, @@ -57,7 +61,7 @@ export const BACKUP_FAILURES = { unknown: { message: "Your key wasn’t backed up.", retryable: false }, } as const satisfies Record< string, - Readonly<{ message: string; retryable: boolean }> + Readonly<{ cause?: string; message: string; retryable: boolean }> >; export type BackupFailureKind = keyof typeof BACKUP_FAILURES;