diff --git a/src/features/identity/BackupOffer.tsx b/src/features/identity/BackupOffer.tsx new file mode 100644 index 000000000..4d84adb58 --- /dev/null +++ b/src/features/identity/BackupOffer.tsx @@ -0,0 +1,41 @@ +import { Button } from "../../shared/design-system/ui/Button"; + +/** + * Pause after an imported key, on either lane. Importing must never upload + * silently: the backup is offered, and the key continues either way. The key + * may be used elsewhere, so the copy discloses that Block can restore it there + * too; choosing Back it up is the backup consent. + */ +export function BackupOffer({ + onSkip, + onBackUp, +}: { + onSkip: () => void; + onBackUp: () => void; +}) { + return ( + <> +
+

+ Back up this key? +

+

+ Buzz can keep an encrypted copy in your account so you can skip this + step on your next device. Block can use that copy to restore this key, + including anywhere else you use it. +

+
+ {/* Footer order: secondary first, primary last. */} +
+ + +
+ + ); +} diff --git a/src/features/identity/CommunityUrlField.test.ts b/src/features/identity/CommunityUrlField.test.ts new file mode 100644 index 000000000..74632d467 --- /dev/null +++ b/src/features/identity/CommunityUrlField.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from "vitest"; +import { enteredCommunity } from "./CommunityUrlField"; + +it.each([ + ["relay.example.com", "https://relay.example.com", ""], + [" Relay.Example.com:8443 ", "https://relay.example.com:8443", ""], + ["wss://relay.example.com/", "https://relay.example.com", ""], + ["https://relay.example.com", "https://relay.example.com", ""], + [ + "https://Relay.Example.com:8443/invite/v2.Invite-code_1", + "https://relay.example.com:8443", + "v2.Invite-code_1", + ], + [ + "https://relay.example.com/invite/v2.code/", + "https://relay.example.com", + "v2.code", + ], + [ + "buzz://join?relay=wss%3A%2F%2Frelay.example.com%3A8443&code=v2.code", + "https://relay.example.com:8443", + "v2.code", + ], + // Windows hands the app link over with a root path. + [ + "buzz://join/?relay=wss%3A%2F%2Frelay.example.com&code=v2.code&policy_receipt=r", + "https://relay.example.com", + "v2.code", + ], +])("reads %j as %s with code %j", (input, origin, code) => { + expect(enteredCommunity(input)).toEqual({ origin, code }); +}); + +it.each([ + "", + "not a url", + "ws://relay.example.com", + "https://relay.example.com/other", + "relay.example.com/invite/v2.code", + "http://relay.example.com/invite/v2.code", + "https://user@relay.example.com/invite/v2.code", + "https://relay.example.com/invite/v2.code?next=evil", + "buzz://join?relay=wss%3A%2F%2Frelay.example.com", + "buzz://join?relay=ws%3A%2F%2Frelay.example.com&code=v2.code", +])("refuses %j", (input) => { + expect(enteredCommunity(input)).toBeUndefined(); +}); diff --git a/src/features/identity/CommunityUrlField.tsx b/src/features/identity/CommunityUrlField.tsx new file mode 100644 index 000000000..5b0199160 --- /dev/null +++ b/src/features/identity/CommunityUrlField.tsx @@ -0,0 +1,88 @@ +import { useId, useState } from "react"; +import { enteredRelayOrigin } from "../communities/destination"; +import { parsePastedInvite } from "../communities/invite-link"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Field } from "../../shared/design-system/ui/Field"; +import { Input } from "../../shared/design-system/ui/Input"; + +/** + * A typed address (a bare host is a secure relay) with no code, else an invite + * link — the relay's `https:///invite/` page or the `buzz://join` + * link it opens — with its code. A bare address never resolves an alias. + */ +export function enteredCommunity(url: string) { + try { + return { origin: enteredRelayOrigin(url), code: "" }; + } catch { + const invite = parsePastedInvite(url); + return invite?.code + ? { origin: invite.community, code: invite.code } + : undefined; + } +} + +/** + * The community URL field with its Continue button, shared by the first-run + * landing and Join a community. Enter is handled by the input, not a native + * form submit, so it reports an empty or invalid address whatever the button's + * state; Continue therefore stays enabled (only `disabled` turns it off) and a + * click shows the same error under the field. Continue only starts the lookup; + * the join itself happens on Join Community. + */ +export function CommunityUrlField({ + url, + onUrlChange, + onSubmit, + disabled = false, +}: { + url: string; + onUrlChange: (url: string) => void; + /** Receives the canonical secure origin, and an invite link's code ("" for an address). */ + onSubmit: (origin: string, code: string) => void; + disabled?: boolean; +}) { + const [invalid, setInvalid] = useState(false); + const errorId = useId(); + const entered = enteredCommunity(url); + const submit = () => { + if (entered) onSubmit(entered.origin, entered.code); + else setInvalid(true); + }; + return ( +
+
+
+ + { + onUrlChange(event.target.value); + setInvalid(false); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing) + return; + event.preventDefault(); + submit(); + }} + /> + +
+ +
+ {invalid && ( + + )} +
+ ); +} diff --git a/src/features/identity/ConnectChoice.tsx b/src/features/identity/ConnectChoice.tsx new file mode 100644 index 000000000..67756de91 --- /dev/null +++ b/src/features/identity/ConnectChoice.tsx @@ -0,0 +1,59 @@ +import { Button } from "../../shared/design-system/ui/Button"; + +/** + * Pause on a found community before any sign-in: the relay cannot sign the + * user in, but Buzz still needs a key. Connect runs + * the build portal's sign-in exactly as the landing's button does (the key it + * creates is linked to that account and backed up there); the import brings a + * key with no account to link it to. Either way setup ends on Join Community + * for the found address. + */ +export function ConnectChoice({ + host, + onConnect, + onImport, + onBack, +}: { + /** The found community's host, as the find page shows it. */ + host: string; + /** Sign in at the build portal and continue as the landing’s Sign in with email does. */ + onConnect: () => void; + /** The user already has a key: open the import page. */ + onImport: () => void; + /** Return to the landing with the address kept. */ + onBack: () => void; +}) { + return ( + <> +
+

+ Connect to your community +

+ {/* The host stands apart from the title so a long one wraps on its own + line and the title reads as one phrase to assistive technology. */} +

+ {host} +

+

+ Buzz relies on a key for your identity. +

+
+
+ + {/* The secondary choice is deliberately smaller than the primary. */} + +
+
+ +
+ + ); +} diff --git a/src/features/identity/InviteCodeField.tsx b/src/features/identity/InviteCodeField.tsx new file mode 100644 index 000000000..57bcce152 --- /dev/null +++ b/src/features/identity/InviteCodeField.tsx @@ -0,0 +1,73 @@ +import { useId, useState } from "react"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Field } from "../../shared/design-system/ui/Field"; +import { Input } from "../../shared/design-system/ui/Input"; + +/** + * The invite page's code field with its Join button. A refusal shows under + * the field until the code is edited; the typed code is kept either way. + */ +export function InviteCodeField({ + error, + busy, + pending, + onSubmit, +}: { + /** Why the relay refused the last code. */ + error?: string | undefined; + /** Something on the page is in flight: nothing can be submitted. */ + busy: boolean; + /** This field's own claim is the one in flight. */ + pending: boolean; + onSubmit: (code: string) => void; +}) { + const [code, setCode] = useState(""); + const [edited, setEdited] = useState(false); + const errorId = useId(); + const shown = edited ? undefined : error; + const value = code.trim(); + const submit = () => { + if (!value || busy) return; + setEdited(false); + onSubmit(value); + }; + // `CommunityUrlField`'s recipe: the error sits below the row, so Join stays + // aligned with the input. + return ( +
+
+
+ + { + setCode(event.target.value); + setEdited(true); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing) + return; + event.preventDefault(); + submit(); + }} + /> + +
+ +
+ {shown && ( + + )} +
+ ); +} diff --git a/src/features/identity/JoinCommunity.tsx b/src/features/identity/JoinCommunity.tsx new file mode 100644 index 000000000..7a0ce6194 --- /dev/null +++ b/src/features/identity/JoinCommunity.tsx @@ -0,0 +1,82 @@ +import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { Button } from "../../shared/design-system/ui/Button"; +import type { OwnedCommunity } from "./builderlab-account"; +import { CommunityUrlField } from "./CommunityUrlField"; +import { OnboardingScreen } from "./OnboardingScreen"; + +/** + * First-run landing after setup. The community URL is the live action; a + * linked account's owned communities are offered as rows that take the same + * path with their address. + */ +export function JoinCommunity({ + url, + onUrlChange, + onFind, + owned = [], +}: { + url: string; + onUrlChange: (url: string) => void; + /** Receives the canonical secure origin, and an invite link's code when one was pasted. */ + onFind: (origin: string, code?: string) => void; + owned?: readonly OwnedCommunity[] | undefined; +}) { + // The in-app Join a community landing's layout (CommunityLanding). + return ( + +

+ Join a community +

+
+ {owned.length > 0 && ( + // min-w-0 keeps a long hostname truncating rather than widening the grid. +
+

+ Communities you own +

+
    + {owned.map(({ host, url: origin }) => ( +
  • + +

    + {host} +

    + +
  • + ))} +
+
+ )} + +
+ + } + > + + +
+ ); +} diff --git a/src/features/identity/KeyChoice.tsx b/src/features/identity/KeyChoice.tsx new file mode 100644 index 000000000..ea4496a96 --- /dev/null +++ b/src/features/identity/KeyChoice.tsx @@ -0,0 +1,75 @@ +import { Button } from "../../shared/design-system/ui/Button"; +import styles from "./IdentitySetup.module.css"; +import { OnboardingScreen } from "./OnboardingScreen"; + +/** + * Pause on an unlinked account: create a key, which is then linked and backed + * up, or bring an existing one. The copy discloses that Block can restore the + * key, since choosing to create it is the backup consent. + */ +export function KeyChoice({ + email, + backups, + onCreate, + onImport, + onSwitchAccount, +}: { + /** The signed-in Builderlab account the lookup ran for. */ + email: string | undefined; + /** + * The keys the backed-up-key lookup listed; `undefined` when it was + * unavailable or failed. Backups outlive an unlink, so an unlinked account + * may still list some, and only an empty list says none was found. + */ + backups: readonly string[] | undefined; + /** Create the key, link it to the account, then back it up. */ + onCreate: () => void; + /** The user already has a key: open the import page. */ + onImport: () => void; + /** Wrong account: sign the session out and return to the landing. */ + onSwitchAccount: () => void; +}) { + // The welcome screen's create-or-import stack, after sign-in. + return ( + +

+ Set up your key +

+

+ Buzz signs everything you do with a private key kept on this device. + {backups?.length === 0 && + " We didn’t find a backed-up key for this account, so let’s make one."}{" "} + An encrypted copy is backed up to your account, and Block can use it + to restore the key on your other devices. +

+ {/* Who the key would belong to, so a wrong account is visible before any key exists. */} +

+ {email ? `Signed in as ${email}.` : "Signed in."} +

+ + } + > + + + +
+ ); +} diff --git a/src/features/identity/KeyImport.tsx b/src/features/identity/KeyImport.tsx new file mode 100644 index 000000000..e982545f0 --- /dev/null +++ b/src/features/identity/KeyImport.tsx @@ -0,0 +1,213 @@ +import { useId, useRef, useState } from "react"; +import { npubEncode } from "nostr-tools/nip19"; +import { useFileDrop } from "../messages/use-file-drop"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Field } from "../../shared/design-system/ui/Field"; +import { Input } from "../../shared/design-system/ui/Input"; +import styles from "./IdentitySetup.module.css"; +import { classifyKeyImport, readKeyFile } from "./key-import"; +import { OnboardingScreen } from "./OnboardingScreen"; +import { SetupProgress } from "./SetupProgress"; + +/** + * Pause for a key the user already has: paste or type an `nsec1…` key, or + * drop or choose a small text file holding one. Valid input previews the + * npub; Continue hands the nsec to native import. The key exists only in this + * component's state and is never logged or stored, so leaving the page + * discards it. While import runs the form gives way to the progress page on + * the same screen but stays mounted, so a native refusal returns to it with + * the typed key kept. + * With `expectedPubkey`, only that key continues: any other valid nsec is + * refused under the field. + */ +export function KeyImport({ + expectedPubkey, + onBack, + onImport, +}: { + /** The hex key linked to the signed-in account, when the lane has one. */ + expectedPubkey?: string | undefined; + onBack: () => void; + /** Rejects with the native refusal, shown under the field. */ + onImport: (nsec: string) => Promise; +}) { + const [text, setText] = useState(""); + const [revealed, setRevealed] = useState(false); + const [busy, setBusy] = useState(false); + const [failure, setFailure] = useState(); + const form = useRef(null); + const formId = useId(); + const picker = useRef(null); + const input = classifyKeyImport(text); + const expected = + expectedPubkey === undefined ? undefined : npubEncode(expectedPubkey); + const mismatch = + input.kind === "nsec" && expected !== undefined && input.npub !== expected; + const ready = input.kind === "nsec" && !mismatch; + const change = (next: string) => { + setText(next); + setFailure(undefined); + // Masked by default must re-assert for the next key pasted. + if (!next.trim()) setRevealed(false); + }; + const fromFile = async (file: File | undefined) => { + if (!file) return; + try { + change(await readKeyFile(file)); + } catch (error) { + setFailure( + error instanceof Error ? error.message : "Couldn’t read that file.", + ); + } + }; + // The whole form is the drop target; the hook rebinds when the form returns. + const dragging = useFileDrop(form, !busy, (files) => void fromFile(files[0])); + const submit = async () => { + if (!ready || busy) return; + setBusy(true); + try { + await onImport(input.nsec); + } catch (error) { + setFailure( + error instanceof Error ? error.message : "Couldn’t import this key.", + ); + } finally { + setBusy(false); + } + }; + const message = + failure ?? + (mismatch ? ( + <> + That isn’t the key linked to this account. It expects{" "} + + {expected} + {" "} + (the key on your other device). + + ) : input.kind === "encrypted" ? ( + "Password-protected backups aren’t supported here yet. Paste the nsec1 key itself." + ) : input.kind === "invalid" ? ( + "That doesn’t look like a valid nsec. Paste an nsec1 key." + ) : undefined); + return ( + + + + ) : ( + <> + {/* The onboarding import layout: the actions below submit through `form`. */} +
{ + event.preventDefault(); + void submit(); + }} + > +

+ Enter your private key +

+

+ Paste your private key to sign in to Buzz. You can also drop a + text file holding it here, or choose one. +

+
+ + Public identity:{" "} + + {input.npub} + + + ) + } + > + + +
+
+ + +
+

+ {dragging + ? "Drop to use this file." + : "A key file is a small text file whose first line is the nsec1 key."} +

+
+
+
+ {/* Outside the form, so the key is its only field and Enter submits. */} + { + const file = event.target.files?.[0]; + event.target.value = ""; + void fromFile(file); + }} + /> + + ) + } + > + {!busy && ( + <> + + + + )} +
+ ); +} diff --git a/src/features/identity/KeyLost.tsx b/src/features/identity/KeyLost.tsx new file mode 100644 index 000000000..363e8eec3 --- /dev/null +++ b/src/features/identity/KeyLost.tsx @@ -0,0 +1,73 @@ +import { Button } from "../../shared/design-system/ui/Button"; +import type { OwnedCommunity } from "./builderlab-account"; +import { WireframePanel } from "./Wireframe"; + +/** + * Pause for a linked key the user no longer has. Starting over creates a new + * key, unlinks this one, links the new one in its place and backs it up, with + * no key choice between; the page spells out what the old key keeps and, as + * Set up your key does, that Block can restore the new one. + */ +export function KeyLost({ + owned, + onBack, + onStartOver, +}: { + /** The communities the old key owns; `undefined` when the list could not be loaded. */ + owned: readonly OwnedCommunity[] | undefined; + onBack: () => void; + onStartOver: () => void; +}) { + return ( + <> +
+

+ Start over with a new key +

+

+ A new key is a new identity. Everything the old key could reach stays + with it: its profile, its messages and the communities it belongs to. + Starting over unlinks the old key, then creates a new key, links it to + this account and keeps an encrypted copy that Block can use to restore + it. +

+
+ +

Communities you own

+ {owned === undefined ? ( +

+ Couldn’t load the communities this account owns. +

+ ) : owned.length === 0 ? ( +

None

+ ) : ( +
    + {owned.map(({ host, url }) => ( +
  • + {host} +
  • + ))} +
+ )} +

+ Memberships in other people’s communities can’t be listed here and + will also be lost. +

+
+ {/* Footer order: secondary first, primary last. */} +
+ + +
+ + ); +} diff --git a/src/features/identity/KeyRestore.tsx b/src/features/identity/KeyRestore.tsx new file mode 100644 index 000000000..0e09a4904 --- /dev/null +++ b/src/features/identity/KeyRestore.tsx @@ -0,0 +1,98 @@ +import { useSyncExternalStore } from "react"; +import { Button } from "../../shared/design-system/ui/Button"; +import type { Recovery } from "./builderlab-account"; +import styles from "./IdentitySetup.module.css"; +import { OnboardingScreen } from "./OnboardingScreen"; +import { RestoreIdentity } from "./RestoreIdentity"; +import { recoveryOff } from "./service"; + +/** + * Pause after Welcome back's Restore from backup: native recovery's browser + * sign-in, then the linked key's backup to install. Where recovery is + * unavailable or refused, which no retry changes, or no backup holds the + * linked key, the page offers Enter your private key instead of Try again. + */ +export function KeyRestore({ + recovery, + expectedPubkey, + onBack, + onImport, + onInstalled, +}: { + recovery: Recovery; + /** The hex key linked to the account: the only one restored. */ + expectedPubkey: string; + /** Recovery was canceled: return to Welcome back. */ + onBack: () => void; + /** Recovery was canceled: open the import page. */ + onImport: () => void; + /** Installing settled, restored or not. */ + onInstalled: () => void; +}) { + const state = useSyncExternalStore(recovery.subscribe, recovery.snapshot); + // A recovery holds the identity busy, so leaving cancels it first. + const leave = (then: () => void) => () => + recovery.cancelRecovery().then((canceled) => { + if (canceled) then(); + return canceled; + }); + const off = recoveryOff(state); + // "Stored securely" holds only while a restore may still install the key: + // an outcome that restored nothing explains itself below. + const restoring = + state?.status === "waiting" || + (state?.status === "choosing" && + state.candidates.some( + (candidate) => candidate.pubkey === expectedPubkey, + )); + return ( + +

+ Restore from backup +

+ {(off || restoring) && ( +

+ {off + ? "Backup restore isn’t available for this account. Enter your private key to continue." + : "Your key is stored securely on this device."} +

+ )} + {off ? ( + // Footer order: secondary first, primary last. +
+ + +
+ ) : state ? ( + + recovery.installRecovery(handle).then(onInstalled), + }} + recovery={state} + expectedPubkey={expectedPubkey} + onImport={() => void leave(onImport)()} + /> + ) : null} + + } + > + {null} +
+ ); +} diff --git a/src/features/identity/ProfileStep.test.tsx b/src/features/identity/ProfileStep.test.tsx new file mode 100644 index 000000000..1192af7ff --- /dev/null +++ b/src/features/identity/ProfileStep.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ComponentProps } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { ProfileStep } from "./ProfileStep"; + +const found = { + url: "https://relay.example.com", + host: "relay.example.com", +}; +const none = { + existing: {}, + profile: { name: "", picture: "", about: "" }, + exists: false, +}; +function deferred() { + let resolve!: () => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} +function step(props: Partial> = {}) { + const onContinue = vi.fn["onContinue"]>( + () => Promise.resolve(), + ); + const onSkip = vi.fn["onSkip"]>(() => + Promise.resolve(), + ); + render( + , + ); + return { onContinue, onSkip, user: userEvent.setup() }; +} +afterEach(() => { + cleanup(); + vi.resetAllMocks(); +}); + +it("suggests and focuses the default name, needs a name to continue, and hands the trimmed profile to Continue", async () => { + const { onContinue, onSkip, user } = step(); + const name = screen.getByRole("textbox", { name: "Display name" }); + expect(name).toHaveValue("ada"); + expect(name).toHaveFocus(); + await user.clear(name); + expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled(); + await user.type(name, " Ada "); + await user.type( + screen.getByRole("textbox", { name: "Profile description (optional)" }), + "Hi ", + ); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(onContinue).toHaveBeenCalledExactlyOnceWith({ + name: "Ada", + picture: "", + about: "Hi", + }); + expect(onSkip).not.toHaveBeenCalled(); +}); + +it("prefills a relay profile and hands it to Continue as it is", async () => { + const profile = { name: "Ada", picture: "", about: "Hi" }; + const { onContinue, user } = step({ + read: { existing: { name: "Ada", about: "Hi" }, profile, exists: true }, + defaultName: "ignored", + }); + expect(screen.getByRole("textbox", { name: "Display name" })).toHaveValue( + "Ada", + ); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(onContinue).toHaveBeenCalledExactlyOnceWith(profile); +}); + +it("holds both buttons while Continue runs, and a rejection stays on the step with the error inline and the draft kept", async () => { + const gate = deferred(); + const { onContinue, onSkip, user } = step(); + onContinue.mockReturnValueOnce(gate.promise); + const name = screen.getByRole("textbox", { name: "Display name" }); + await user.clear(name); + await user.type(name, "Ada{Enter}"); + expect(onContinue).toHaveBeenCalledOnce(); + expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Skip for now" })).toBeDisabled(); + expect(name).toBeDisabled(); + gate.reject( + new Error( + "Your profile change is not current. Your edits are retained; try again.", + ), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Your profile change is not current. Your edits are retained; try again.", + ); + expect(name).toHaveValue("Ada"); + expect(name).toBeEnabled(); + expect(screen.getByRole("button", { name: "Continue" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Skip for now" })).toBeEnabled(); + expect(onSkip).not.toHaveBeenCalled(); +}); + +it("classifies a denied read, and Skip for now hands off without the draft and keeps the step when it fails", async () => { + const { onContinue, onSkip, user } = step(); + onContinue.mockRejectedValueOnce( + Object.assign(new Error("Relay read failed (403)"), {}), + ); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "relay.example.com no longer admits this key. Skip for now and ask a community administrator.", + ); + onSkip.mockRejectedValueOnce( + new Error("Could not save this community on this device. Try again."), + ); + await user.click(screen.getByRole("button", { name: "Skip for now" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not save this community on this device. Try again.", + ); + expect(onSkip).toHaveBeenCalledExactlyOnceWith(); + expect(screen.getByRole("button", { name: "Skip for now" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Skip for now" })); + expect(onSkip).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("alert")).toBeNull(); +}); diff --git a/src/features/identity/ProfileStep.tsx b/src/features/identity/ProfileStep.tsx new file mode 100644 index 000000000..ac6338e9f --- /dev/null +++ b/src/features/identity/ProfileStep.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; +import type { inspectProfile } from "../communities/api"; +import type { FoundCommunity } from "../communities/find-community"; +import { canSaveProfile, ProfileFields } from "../communities/ProfileFields"; +import type { PersonalProfile } from "../communities/service"; +import { readErrorKind } from "../relay/errors"; +import { Button } from "../../shared/design-system/ui/Button"; +import { WireframePanel } from "./Wireframe"; + +/** + * First-run profile, shown once the relay has admitted the key and before the + * gate lifts. A pure form: the caller owns what Continue and Skip do with the + * draft (the join's completion, which publishes the kind 0 through the shared + * owner and saves the membership after it), and a rejection stays here with + * the error inline and the draft intact. + */ +export function ProfileStep({ + found, + read, + defaultName, + onContinue, + onSkip, +}: { + found: Pick; + /** The admission read that joined the community: the prefill. */ + read: Awaited>; + /** Suggested display name when the relay holds no profile for this key. */ + defaultName: string; + /** Publishes the trimmed draft and finishes the join; a rejection keeps the step. */ + onContinue: (profile: PersonalProfile) => Promise; + /** Finishes the join without publishing; a rejection keeps the step. */ + onSkip: () => Promise; +}) { + const [draft, setDraft] = useState(() => + read.exists ? read.profile : { name: defaultName, picture: "", about: "" }, + ); + const [busy, setBusy] = useState(false); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(""); + const canContinue = canSaveProfile(draft) && !busy && !uploading; + const run = async (action: () => Promise) => { + setBusy(true); + setError(""); + try { + await action(); + } catch (reason) { + setError(publishFailure(found.host, reason)); + setBusy(false); + } + }; + return ( +
{ + event.preventDefault(); + if (canContinue) + void run(() => + onContinue({ + ...draft, + name: draft.name.trim(), + about: draft.about?.trim() ?? "", + }), + ); + }} + > +
+

+ Set up your profile +

+

+ Add a name and avatar. They’ll show up on your messages, reactions, + and agent handoffs in {found.host}. You can change them any time in + Settings → Profile. +

+
+ + + + {error && ( +

+ {error} +

+ )} + {/* Footer order: secondary first, primary last. */} +
+ + +
+
+ ); +} + +/** Classified like the join's read failure; the owner's own messages (the + * native route's "Community request failed (500)", an unconfirmed receipt, a + * readback that is not current, a device record that will not save) are + * shown as is. */ +function publishFailure(host: string, error: unknown) { + if (readErrorKind(error) === "denied") + return `${host} no longer admits this key. Skip for now and ask a community administrator.`; + return error instanceof Error + ? error.message + : "Could not reach the relay. Check your connection and try again."; +} diff --git a/src/features/identity/PublicKeyPanel.tsx b/src/features/identity/PublicKeyPanel.tsx new file mode 100644 index 000000000..c31f60c0b --- /dev/null +++ b/src/features/identity/PublicKeyPanel.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { CopyIcon } from "../../shared/design-system/icons/index"; +import { Button } from "../../shared/design-system/ui/Button"; +import { WireframePanel } from "./Wireframe"; + +/** + * A full npub — never hex or an abbreviation — with Copy and inline copy + * feedback, for first-run pages that render outside ToastProvider. The + * Profile panel's public-key recipe. + */ +export function PublicKeyPanel({ + publicKey, + heading = "Your public key (npub)", +}: { + publicKey: string; + heading?: string; +}) { + const [copyStatus, setCopyStatus] = useState(""); + return ( + +
+

{heading}

+ +
+ + {publicKey} + +

+ This is your public identity — it’s safe to share. +

+ + {copyStatus} + +
+ ); +} diff --git a/src/features/identity/RestoreIdentity.tsx b/src/features/identity/RestoreIdentity.tsx index b4b86f6c6..07281d947 100644 --- a/src/features/identity/RestoreIdentity.tsx +++ b/src/features/identity/RestoreIdentity.tsx @@ -6,18 +6,37 @@ import { Field } from "../../shared/design-system/ui/Field"; import { Radio, RadioGroup } from "../../shared/design-system/ui/RadioGroup"; import { type Identity, type RecoveryState, recoveryOff } from "./service"; +/** + * Recovered backups to restore one from. With `expectedPubkey`, only that key + * is offered: when no backup holds it, nothing can be restored, and `onImport` + * offers Enter your private key instead. Try recovery again is offered only + * where a retry may help. + */ export function RestoreIdentity({ identity, recovery, + expectedPubkey, + onImport, }: { - identity: Identity; + identity: Pick< + Identity, + "beginRecovery" | "installRecovery" | "cancelRecovery" + >; recovery: RecoveryState; + /** The hex key linked to the signed-in account, when only it may be restored. */ + expectedPubkey?: string | undefined; + /** No backup holds `expectedPubkey`: leave recovery for the import page. */ + onImport?: (() => void) | undefined; }) { const [selected, setSelected] = useState<{ recovery: RecoveryState; handle: string; }>(); - const candidates = recovery.status === "choosing" ? recovery.candidates : []; + const recovered = recovery.status === "choosing" ? recovery.candidates : []; + const candidates = + expectedPubkey === undefined + ? recovered + : recovered.filter((candidate) => candidate.pubkey === expectedPubkey); const labels = publicKeyLabels( candidates.map((candidate) => candidate.pubkey), ); @@ -29,6 +48,7 @@ export function RestoreIdentity({ : undefined; const handle = selected?.recovery === recovery ? selected.handle : current?.handle; + const mismatched = recovered.length > 0 && candidates.length === 0; return (
{recovery.status === "waiting" ? ( @@ -39,6 +59,11 @@ export function RestoreIdentity({

Canceling recovery…

) : recovery.status === "error" ? (

{recovery.error}

+ ) : mismatched ? ( +

+ None of your backups holds the key linked to this account, so nothing + was restored. +

) : candidates.length === 0 ? (

{recovery.failedCount @@ -104,8 +129,12 @@ export function RestoreIdentity({ > Restore this identity + ) : mismatched && onImport ? ( + ) : (recovery.status === "error" && !recoveryOff(recovery)) || - (recovery.status === "choosing" && candidates.length === 0) ? ( + (recovery.status === "choosing" && recovered.length === 0) ? ( + )} + {/* Secondary choices are deliberately smaller than the primary. */} + + {/* Future entry for NIP-AB device pairing: the signed-in device hands the key over. */} + + {/* Quieter than the other choices: starting over loses the old identity. */} + + {/* A lane exit, not a key choice, so it comes last. */} + + + + ); +} diff --git a/src/features/identity/Wireframe.tsx b/src/features/identity/Wireframe.tsx new file mode 100644 index 000000000..2cac9014e --- /dev/null +++ b/src/features/identity/Wireframe.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; +import { OnboardingScreen } from "./OnboardingScreen"; + +// Unstyled wireframe: first-run pages with no onboarding design yet stack the +// shared controls inside the shared onboarding screen. + +/** A stacked group within a first-run page. */ +export function WireframePanel({ children }: { children: ReactNode }) { + return

{children}
; +} + +/** A first-run page titled by its `welcome-title` heading. */ +export function WireframeScreen({ children }: { children: ReactNode }) { + return ( + + {children} + + } + > + {null} + + ); +} diff --git a/src/features/identity/key-import.test.ts b/src/features/identity/key-import.test.ts new file mode 100644 index 000000000..e4010149d --- /dev/null +++ b/src/features/identity/key-import.test.ts @@ -0,0 +1,61 @@ +import { npubEncode, nsecEncode } from "nostr-tools/nip19"; +import { generateSecretKey, getPublicKey } from "nostr-tools/pure"; +import { expect, it, vi } from "vitest"; +import { + KEY_FILE_MAX_BYTES, + classifyKeyImport, + readKeyFile, +} from "./key-import"; + +const secret = generateSecretKey(); +const nsec = nsecEncode(secret); +const npub = npubEncode(getPublicKey(secret)); + +it("accepts an nsec as typed, trimmed of surrounding whitespace, and previews its npub", () => { + expect(classifyKeyImport(nsec)).toEqual({ kind: "nsec", nsec, npub }); + expect(classifyKeyImport(`\n ${nsec} \r\n`)).toEqual({ + kind: "nsec", + nsec, + npub, + }); + // Bech32 allows an all-uppercase string, and so does native import. + expect(classifyKeyImport(nsec.toUpperCase())).toEqual({ + kind: "nsec", + nsec: nsec.toUpperCase(), + npub, + }); +}); + +it("tells a password-protected backup apart from an invalid key", () => { + expect( + classifyKeyImport( + "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p", + ), + ).toEqual({ kind: "encrypted" }); + expect(classifyKeyImport(" NcryptSec1abc ")).toEqual({ kind: "encrypted" }); + for (const text of [ + "hello", + "nsec1", + `${nsec.slice(0, -1)}${nsec.endsWith("q") ? "p" : "q"}`, + npub, + "ab".repeat(32), + ]) + expect(classifyKeyImport(text)).toEqual({ kind: "invalid" }); + expect(classifyKeyImport("")).toEqual({ kind: "empty" }); + expect(classifyKeyImport(" \n\t")).toEqual({ kind: "empty" }); +}); + +it("reads the first non-blank line of a small key file and refuses a larger one unread", async () => { + const text = vi.fn(async () => `\r\n ${nsec} \r\nsecond line\n`); + await expect(readKeyFile({ size: KEY_FILE_MAX_BYTES, text })).resolves.toBe( + nsec, + ); + await expect( + readKeyFile({ size: 3, text: async () => "\n\n" }), + ).resolves.toBe(""); + const big = vi.fn(async () => nsec); + await expect( + readKeyFile({ size: KEY_FILE_MAX_BYTES + 1, text: big }), + ).rejects.toThrow(/too large to be a private key/); + expect(big).not.toHaveBeenCalled(); +}); diff --git a/src/features/identity/key-import.ts b/src/features/identity/key-import.ts new file mode 100644 index 000000000..199b75038 --- /dev/null +++ b/src/features/identity/key-import.ts @@ -0,0 +1,50 @@ +import { decode, npubEncode } from "nostr-tools/nip19"; +import { getPublicKey } from "nostr-tools/pure"; + +/** What pasted, typed or file-sourced private-key text turned out to be. */ +export type KeyImportInput = + | { kind: "empty" } + /** `nsec` is the trimmed text native import takes as is; `npub` its public identity. */ + | { kind: "nsec"; nsec: string; npub: string } + /** A NIP-49 password-protected backup: nothing here can decrypt it. */ + | { kind: "encrypted" } + | { kind: "invalid" }; + +export function classifyKeyImport(text: string): KeyImportInput { + const trimmed = text.trim(); + if (!trimmed) return { kind: "empty" }; + if (trimmed.slice(0, 10).toLowerCase() === "ncryptsec1") + return { kind: "encrypted" }; + try { + const decoded = decode(trimmed); + if (decoded.type !== "nsec") return { kind: "invalid" }; + // Derivation also rejects a secret outside the curve order, as native import does. + return { + kind: "nsec", + nsec: trimmed, + npub: npubEncode(getPublicKey(decoded.data)), + }; + } catch { + return { kind: "invalid" }; + } +} + +/** Anything larger is not a key file; it is refused before being read. */ +export const KEY_FILE_MAX_BYTES = 1024; + +/** The first non-blank line of a small text file, trimmed. */ +export async function readKeyFile( + file: Pick, +): Promise { + if (file.size > KEY_FILE_MAX_BYTES) + throw new Error( + "That file is too large to be a private key. Choose a small text file whose first line is an nsec1 key.", + ); + const text = await file.text(); + return ( + text + .split(/\r?\n/) + .find((line) => line.trim()) + ?.trim() ?? "" + ); +} diff --git a/src/features/identity/restore-communities.test.ts b/src/features/identity/restore-communities.test.ts new file mode 100644 index 000000000..ef03103ac --- /dev/null +++ b/src/features/identity/restore-communities.test.ts @@ -0,0 +1,185 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { inspectProfile } from "../communities/api"; +import type { ClientSnapshot } from "../communities/service"; +import { ReadError } from "../relay/errors"; +import { restoreKnown } from "./restore-communities"; +import { restoreCommunities } from "./setup"; + +vi.mock("../communities/api", () => ({ inspectProfile: vi.fn() })); +afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); +}); + +type Read = Awaited>; +const empty = { name: "", picture: "", about: "" }; +const found = (name: string): Read => ({ + existing: { name }, + profile: { name, picture: "", about: "" }, + exists: true, +}); +const none: Read = { existing: {}, profile: empty, exists: false }; +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} +function service(status: ClientSnapshot["status"] = "ready") { + return { + snapshot: () => ({ status, profile: empty }) as ClientSnapshot, + joined: vi.fn(), + }; +} +const options = { concurrency: 3, startBy: 45_000 }; +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +it("reads at most three destinations at once and reports outcomes in list order", async () => { + const reads = new Map>>(); + vi.mocked(inspectProfile).mockImplementation((id) => { + const read = deferred(); + reads.set(id, read); + return read.promise; + }); + const communities = service(); + const known = ["a", "b", "c", "d", "e"].map( + (host) => `wss://${host}.example`, + ); + const restoring = restoreKnown(known, communities, options); + await flush(); + expect([...reads.keys()]).toEqual([ + "https://a.example", + "https://b.example", + "https://c.example", + ]); + // The second finishing first frees its slot for the fourth. + reads.get("https://b.example")?.resolve(none); + await flush(); + expect([...reads.keys()]).toHaveLength(4); + for (const read of reads.values()) read.resolve(none); + await flush(); + reads.get("https://e.example")?.resolve(none); + const outcomes = await restoring; + expect(outcomes.map((outcome) => outcome.host)).toEqual([ + "a.example", + "b.example", + "c.example", + "d.example", + "e.example", + ]); + expect(communities.joined).toHaveBeenCalledTimes(5); +}); + +it("joins only the destinations that admit the key, and records refusals and failures as outcomes", async () => { + const ada = found("Ada"); + vi.mocked(inspectProfile).mockImplementation(async (id) => { + if (id === "https://denied.example") + throw new ReadError("denied", "Relay read failed (403)", 403); + if (id === "https://down.example") throw "Community discovery failed"; + return id === "https://ada.example" ? ada : none; + }); + const communities = service(); + const outcomes = await restoreKnown( + [ + "wss://denied.example", + "wss://ada.example", + "wss://down.example", + "wss://new.example:8443", + ], + communities, + options, + ); + expect(outcomes).toEqual([ + { + url: "https://denied.example", + id: "https://denied.example", + host: "denied.example", + kind: "denied", + }, + { + url: "https://ada.example", + id: "https://ada.example", + host: "ada.example", + kind: "admitted", + read: ada, + }, + { + url: "https://down.example", + id: "https://down.example", + host: "down.example", + kind: "unavailable", + }, + { + url: "https://new.example:8443", + id: "https://new.example:8443", + host: "new.example:8443", + kind: "admitted", + read: none, + }, + ]); + // A relay profile is offered to the empty local default; otherwise the + // local default is. + expect(communities.joined.mock.calls).toEqual([ + [{ id: "https://ada.example", name: "ada.example" }, ada.profile], + [{ id: "https://new.example:8443", name: "new.example:8443" }, empty], + ]); +}); + +it("reads a destination listed twice once and skips one this build cannot open", async () => { + vi.mocked(inspectProfile).mockResolvedValue(none); + const outcomes = await restoreKnown( + ["wss://a.example", "https://A.example/", "ws://insecure.example"], + service(), + options, + ); + expect(vi.mocked(inspectProfile).mock.calls).toEqual([["https://a.example"]]); + expect(outcomes.map((outcome) => outcome.kind)).toEqual(["admitted"]); +}); + +it("an admitted destination whose membership cannot be saved, or arrives before the device record is ready, is unavailable", async () => { + vi.mocked(inspectProfile).mockResolvedValue(none); + const failing = service(); + failing.joined.mockImplementation(() => { + throw new Error("Your saved communities could not be stored."); + }); + expect( + await restoreKnown(["wss://a.example"], failing, options), + ).toMatchObject([{ kind: "unavailable" }]); + const loading = service("loading"); + expect( + await restoreKnown(["wss://a.example"], loading, options), + ).toMatchObject([{ kind: "unavailable" }]); + expect(loading.joined).not.toHaveBeenCalled(); +}); + +it("starts no read 45 s after the restore began, leaving the rest unavailable", async () => { + vi.useFakeTimers(); + const first = deferred(); + vi.mocked(inspectProfile).mockReturnValueOnce(first.promise); + const restoring = restoreKnown( + ["wss://a.example", "wss://b.example"], + service(), + { concurrency: 1, startBy: 45_000 }, + ); + await vi.advanceTimersByTimeAsync(45_000); + first.resolve(none); + expect((await restoring).map((outcome) => outcome.kind)).toEqual([ + "admitted", + "unavailable", + ]); + expect(inspectProfile).toHaveBeenCalledTimes(1); +}); + +it("the restore step hands the outcomes to `decide` and rests where it says", async () => { + vi.mocked(inspectProfile).mockResolvedValue(none); + const decide = vi.fn(() => ({ status: "idle" }) as const); + const step = restoreCommunities(["wss://a.example"], service(), decide); + expect(step.label).toBe("Restoring your communities…"); + expect(await step.run()).toEqual({ status: "idle" }); + expect(decide).toHaveBeenCalledWith([ + expect.objectContaining({ id: "https://a.example", kind: "admitted" }), + ]); +}); diff --git a/src/features/identity/restore-communities.ts b/src/features/identity/restore-communities.ts new file mode 100644 index 000000000..8de2f9754 --- /dev/null +++ b/src/features/identity/restore-communities.ts @@ -0,0 +1,81 @@ +import { inspectProfile } from "../communities/api"; +import { communityDestination } from "../communities/destination"; +import type { Communities } from "../communities/service"; +import { readErrorKind } from "../relay/errors"; + +/** + * What restoring one saved destination found: the relay admitted the key and + * the membership is saved (with the read that proved it), refused the key, or + * could not be read or saved. + */ +export type Restored = Readonly<{ + url: string; + id: string; + host: string; + kind: "admitted" | "denied" | "unavailable"; + read?: Awaited>; +}>; + +/** + * Joins each destination saved to the account that admits this key, by the + * same signed profile read and `communities.joined` the first-run join uses. + * At most `concurrency` reads run at once, and none starts `startBy` ms after + * the restore began (each read is itself bounded); one not started is + * unavailable. Outcomes follow the list order, one per destination. Never + * throws: every failure is an outcome. + */ +export async function restoreKnown( + known: readonly string[], + communities: Pick, + { concurrency, startBy }: { concurrency: number; startBy: number }, +): Promise { + const destinations = new Map>(); + for (const address of known) { + try { + const { id, url, name } = communityDestination(address); + if (!destinations.has(id)) destinations.set(id, { url, id, host: name }); + } catch { + // Not a destination this build can open: nothing to restore. + } + } + const pending = [...destinations.values()]; + const deadline = Date.now() + startBy; + const outcomes: Restored[] = []; + // One iterator shared by every worker: each destination is taken once. + const queue = pending.entries(); + const restore = async (destination: (typeof pending)[number]) => { + let read: Awaited>; + try { + read = await inspectProfile(destination.id); + } catch (error) { + return { + ...destination, + kind: readErrorKind(error) === "denied" ? "denied" : "unavailable", + } as const; + } + // A membership saved before the device record hydrates would be lost to it. + const client = communities.snapshot(); + if (client.status !== "ready") + return { ...destination, kind: "unavailable" } as const; + try { + communities.joined( + { id: destination.id, name: destination.host }, + read.exists ? read.profile : client.profile, + ); + } catch { + return { ...destination, kind: "unavailable" } as const; + } + return { ...destination, kind: "admitted", read } as const; + }; + const worker = async () => { + for (const [index, destination] of queue) + outcomes[index] = + Date.now() >= deadline + ? { ...destination, kind: "unavailable" } + : await restore(destination); + }; + await Promise.all( + Array.from({ length: Math.min(concurrency, pending.length) }, worker), + ); + return outcomes; +} diff --git a/src/features/identity/setup.test.ts b/src/features/identity/setup.test.ts new file mode 100644 index 000000000..f6c993eb2 --- /dev/null +++ b/src/features/identity/setup.test.ts @@ -0,0 +1,211 @@ +import { expect, it, vi } from "vitest"; +import type { FirstRunAccount } from "./builderlab-account"; +import { BackupError } from "./backup"; +import { + checkAccount, + checkBackup, + createSetup, + type SetupStep, +} from "./setup"; + +const ok = (label: string): SetupStep => ({ + label, + run: vi.fn(() => Promise.resolve()), +}); +/** Fails until `heal`, with a retryable error when asked. */ +function flaky(label: string, retryable: boolean) { + let broken = true; + const run = vi.fn(async () => { + if (broken) + throw Object.assign(new Error(`${label} failed`), { retryable }); + }); + const step: SetupStep = { label, run }; + return { step, run, heal: () => (broken = false) }; +} + +it("retries from the failed step and rests where the steps would have", async () => { + const setup = createSetup(); + const before = ok("Before"); + const failing = flaky("Linking", true); + const after = ok("After"); + await setup.start([before, failing.step, after], { + status: "paused", + at: "backup-offer", + }); + expect(setup.snapshot()).toEqual({ + status: "failed", + error: "Linking failed", + retryable: true, + skipsLink: false, + }); + + failing.heal(); + await setup.retry(); + expect(setup.snapshot()).toEqual({ status: "paused", at: "backup-offer" }); + expect(before.run).toHaveBeenCalledTimes(1); + expect(failing.run).toHaveBeenCalledTimes(2); + expect(after.run).toHaveBeenCalledTimes(1); +}); + +it("reports only an error that says so as retryable", async () => { + const setup = createSetup(); + await setup.start([flaky("Backing up", false).step]); + expect(setup.snapshot()).toEqual({ + status: "failed", + error: "Backing up failed", + retryable: false, + skipsLink: false, + }); + await setup.start([ + { label: "Odd", run: () => Promise.reject("text") }, + ]); + expect(setup.snapshot()).toMatchObject({ retryable: false }); +}); + +it.each([ + ["the unlink before it", 0, true], + ["the link itself", 1, true], + ["the backup after it", 2, false], +])( + "says Continue skips the link only while a linking step is left: %s failing", + async (_label, failing, skipsLink) => { + const run = (index: number) => () => + index === failing + ? Promise.reject(new Error("Failed")) + : Promise.resolve(); + const setup = createSetup(); + await setup.start([ + { label: "Unlinking", run: run(0) }, + { label: "Linking", run: run(1), links: true }, + { label: "Backing up", run: run(2) }, + ]); + expect(setup.snapshot()).toMatchObject({ status: "failed", skipsLink }); + }, +); + +it("ignores Try again unless failed, and a second click", async () => { + const setup = createSetup(); + await setup.retry(); + expect(setup.snapshot()).toEqual({ status: "idle" }); + + let release!: () => void; + const run = vi + .fn<() => Promise>() + .mockRejectedValueOnce( + Object.assign(new Error("Backing up failed"), { retryable: true }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const after = ok("After"); + await setup.start([{ label: "Backing up", run }, after]); + const first = setup.retry(); + expect(setup.snapshot()).toEqual({ status: "running", label: "Backing up" }); + await setup.retry(); + release(); + await first; + expect(run).toHaveBeenCalledTimes(2); + expect(after.run).toHaveBeenCalledTimes(1); + expect(setup.snapshot()).toEqual({ status: "done" }); +}); + +it("reads the linked key and the saved destinations together, and a failed list never fails setup", async () => { + const pubkey = "ab".repeat(32); + const check = async ( + linkedKey: FirstRunAccount["linkedKey"], + knownCommunities: FirstRunAccount["knownCommunities"], + ) => { + const onLinked = vi.fn(); + const onKnown = vi.fn(); + const setup = createSetup(); + await setup.start([ + checkAccount( + { linkedKey, knownCommunities } as FirstRunAccount, + onLinked, + onKnown, + ), + ]); + return { + rest: setup.snapshot(), + linked: onLinked.mock.calls, + known: onKnown.mock.calls, + }; + }; + // Both reads start before either answers. + let answerKey!: (pubkey: string | null) => void; + let answerList!: (known: readonly string[]) => void; + const linkedKey = vi.fn( + () => new Promise((resolve) => (answerKey = resolve)), + ); + const knownCommunities = vi.fn( + () => new Promise((resolve) => (answerList = resolve)), + ); + const pending = check(linkedKey, knownCommunities); + expect(linkedKey).toHaveBeenCalledTimes(1); + expect(knownCommunities).toHaveBeenCalledTimes(1); + answerList(["wss://a.example"]); + answerKey(pubkey); + expect(await pending).toEqual({ + rest: { status: "paused", at: "welcome-back" }, + linked: [[pubkey]], + known: [[["wss://a.example"]]], + }); + expect( + await check( + () => Promise.resolve(null), + () => Promise.reject(new Error("Builderlab known communities failed")), + ), + ).toEqual({ rest: { status: "done" }, linked: [], known: [[undefined]] }); + // The key read still decides setup. + expect( + await check( + () => Promise.reject(new Error("Builderlab account check failed")), + () => Promise.resolve([]), + ), + ).toMatchObject({ + rest: { status: "failed", error: "Builderlab account check failed" }, + linked: [], + known: [], + }); +}); + +it("reports the backed-up keys, and an unavailable or failed status as none listed without failing setup", async () => { + const listed = "ab".repeat(32); + const lookup = async (status: FirstRunAccount["backupStatus"]) => { + const onListed = vi.fn(); + const setup = createSetup(); + await setup.start([ + checkBackup({ backupStatus: status } as FirstRunAccount, onListed), + ]); + expect(setup.snapshot()).toEqual({ status: "done" }); + return onListed.mock.calls; + }; + expect(await lookup(() => Promise.resolve([listed]))).toEqual([[[listed]]]); + expect(await lookup(() => Promise.resolve([]))).toEqual([[[]]]); + expect(await lookup(() => Promise.resolve("unavailable"))).toEqual([ + [undefined], + ]); + expect( + await lookup(() => Promise.reject(new BackupError("unreachable"))), + ).toEqual([[undefined]]); +}); + +it("a step that rests idle ends setup past the caller's rest, also when a retry reaches it", async () => { + const setup = createSetup(); + const failing = flaky("Linking", true); + const enter: SetupStep = { + label: "Restoring", + run: async () => ({ status: "idle" }) as const, + }; + await setup.start([failing.step, enter], { status: "done" }); + expect(setup.snapshot()).toMatchObject({ status: "failed" }); + failing.heal(); + await setup.retry(); + expect(setup.snapshot()).toEqual({ status: "idle" }); + // Idle, unlike done, lets setup start again. + await setup.start([], { status: "paused", at: "key-choice" }); + expect(setup.snapshot()).toEqual({ status: "paused", at: "key-choice" }); +}); diff --git a/src/features/identity/setup.ts b/src/features/identity/setup.ts new file mode 100644 index 000000000..901f2b4ee --- /dev/null +++ b/src/features/identity/setup.ts @@ -0,0 +1,300 @@ +import type { Communities } from "../communities/service"; +import type { FirstRunAccount, OwnedCommunity } from "./builderlab-account"; +import { type Restored, restoreKnown } from "./restore-communities"; +import type { Identity } from "./service"; + +/** A step may pick where setup rests afterwards, overriding the caller's `then`. */ +export type SetupStep = Readonly<{ + label: string; + run: () => Promise | Promise; + /** Links the key on this device to the account: skipping it leaves the key unlinked. */ + links?: boolean; +}>; +/** + * Where setup rests for a decision: the connect choice for a found + * community, the key choice, an import, the backup offer after an import, + * or the returning-user pages (Welcome back, a restore from backup, the + * start-over page for a lost key). + */ +export type SetupPause = + | "connect-choice" + | "key-choice" + | "key-import" + | "welcome-back" + | "backup-offer" + | "key-restore" + | "key-lost"; +export type SetupSnapshot = Readonly< + | { status: "idle" } + | { status: "running"; label: string } + | { status: "paused"; at: SetupPause } + | { + status: "failed"; + error: string; + /** The error says Try again can help, as a retryable `BackupError` does. */ + retryable: boolean; + /** Continuing past the failure skips the link, so the key stays unlinked. */ + skipsLink: boolean; + } + | { status: "done" } +>; +/** Idle ends setup outright: with a key on the device, the gate lifts. */ +export type Resting = Extract< + SetupSnapshot, + { status: "idle" | "paused" | "done" } +>; + +/** The key now on this device, which the account steps act on. */ +function viewer(identity: Identity) { + const state = identity.snapshot(); + if (state.status !== "ready") + throw new Error("Your key could not be saved on this device."); + return state.viewer; +} + +/** `identity.create` and `importKey` report failure through the snapshot, not by throwing. */ +function saved(identity: Identity) { + const state = identity.snapshot(); + if (state.status === "ready") return; + throw new Error( + (state.status === "missing" && state.error) || + "Your key could not be saved on this device.", + ); +} + +/** Creates a key on this device, which the account steps then act on. */ +export function createKey(identity: Identity): SetupStep { + return { + label: "Creating keys…", + run: async () => { + await identity.create(); + saved(identity); + }, + }; +} + +/** + * Stores a key the user already has through the key setup screen's native + * import command, which takes the nsec as typed. Not a step: a refusal belongs + * under the import page's field, not on the failed page. + */ +export async function importKey(identity: Identity, nsec: string) { + await identity.importKey(nsec); + saved(identity); +} + +/** + * Real: the key bound to the signed-in account picks the lane. Binding is 1:1 + * and enforced, so a linked account must never be given a second key: it rests + * on Welcome back instead of the key choice, and later steps still run. The + * destinations saved to the account are read alongside, under the same label; + * that list never fails setup and is reported as `undefined` when it did. + */ +export function checkAccount( + account: FirstRunAccount, + onLinked: (pubkey: string) => void, + onKnown: (known: readonly string[] | undefined) => void, +): SetupStep { + return { + label: "Checking your account…", + run: async () => { + const [pubkey, known] = await Promise.all([ + account.linkedKey(), + account.knownCommunities().catch(() => undefined), + ]); + onKnown(known); + if (!pubkey) return undefined; + onLinked(pubkey); + return { status: "paused", at: "welcome-back" }; + }, + }; +} + +/** + * Real: binds the key now on this device to the signed-in account, so the + * account is this key's and a backup can be accepted for it. The account sends + * nothing more when it already holds the key, so a re-run is harmless. + */ +export function linkKey( + identity: Identity, + account: FirstRunAccount, +): SetupStep { + return { + label: "Linking your key…", + run: () => account.bind(viewer(identity)), + links: true, + }; +} + +/** + * Real: unbinds `old`, the key the account held before starting over, so the + * new key created on this device can be linked in its place: binding is 1:1 + * and never overwrites. Runs once that key exists, just before `linkKey`, as + * the hosted card's switch does. The account deletes only while it still holds + * `old`, so a re-run never removes the new key's binding, and the step fails + * unless the account is then left with no key or the new one. + */ +export function unlinkKey( + identity: Identity, + account: FirstRunAccount, + old: string, +): SetupStep { + return { + label: "Unlinking your old key…", + run: () => account.unbind(old, viewer(identity)), + }; +} + +/** + * Real: backs the key now on this device up to the signed-in account and waits + * for the native uploader's own result (`backUpWith`), which kgoose accepts + * only for the key bound to the account: the linked lane's own, or one + * `linkKey` bound first. + */ +export function backUp( + identity: Identity, + account: FirstRunAccount, +): SetupStep { + return { + label: "Backing up your key…", + run: () => account.backUp(viewer(identity)), + }; +} + +/** + * Real: the communities the linked account owns, for Join a community's rows + * and the start-over page. Runs only once a key is on the device and the + * account is linked (the list is meaningless otherwise). A failed list is + * reported as `undefined`, not a failed setup: the pages work without it. + */ +export function findCommunities( + account: FirstRunAccount, + onFound: (owned: readonly OwnedCommunity[] | undefined) => void, +): SetupStep { + return { + label: "Finding communities…", + run: () => + account.ownedCommunities().then(onFound, () => onFound(undefined)), + }; +} + +/** + * Real: joins the destinations saved to the account that admit the key now on + * this device (`restoreKnown`), then lets `decide` pick where setup rests from + * the outcomes. Never fails setup: a destination that was refused or could not + * be read is an outcome, not an error. + */ +export function restoreCommunities( + known: readonly string[], + communities: Pick, + decide: (outcomes: readonly Restored[]) => Resting | undefined, +): SetupStep { + return { + label: "Restoring your communities…", + run: async () => + decide( + await restoreKnown(known, communities, { + concurrency: 3, + startBy: 45_000, + }), + ), + }; +} + +/** + * Real: the keys the signed-in account holds a backup of, which decide whether + * Welcome back offers a restore and an import still needs the backup offer. + * Reported as `undefined` where backup status is unavailable (most Builderlab + * deployments today) or failed, never as a failed setup: the pages work without it, and + * no copy implies the user lost a backup. + */ +export function checkBackup( + account: FirstRunAccount, + onListed: (pubkeys: readonly string[] | undefined) => void, +): SetupStep { + return { + label: "Checking for a backed-up key…", + run: () => + account.backupStatus().then( + (listed) => onListed(listed === "unavailable" ? undefined : listed), + () => onListed(undefined), + ), + }; +} + +/** + * Runs steps in order, stopping at the first failure, then rests at `then` — + * done, or a pause the caller resumes with another `start` — unless a step + * picked another resting state. No steps means "nothing more to do" and + * rests at once. A failed setup may also be resumed: by `retry` from the + * failed step, or once a key exists, by Continue from the failed page, which + * skips the steps left and rests on the Join page rather than the landing. + */ +export function createSetup() { + let state: SetupSnapshot = { status: "idle" }; + // From the failed step on, with where setup would have rested. Taken once, + // so a second click on Try again does nothing. + let failed: + | Readonly<{ steps: readonly SetupStep[]; rest: Resting }> + | undefined; + const listeners = new Set<() => void>(); + const publish = (next: SetupSnapshot) => { + state = next; + for (const listener of listeners) listener(); + }; + async function run(steps: readonly SetupStep[], then: Resting) { + let rest = then; + for (const [index, step] of steps.entries()) { + publish({ status: "running", label: step.label }); + try { + const picked = await step.run(); + if (picked) rest = picked; + } catch (error) { + const left = steps.slice(index); + failed = { steps: left, rest }; + publish({ + status: "failed", + error: + error instanceof Error ? error.message : "Setup failed. Try again.", + retryable: + error instanceof Error && + "retryable" in error && + error.retryable === true, + skipsLink: left.some((step) => step.links === true), + }); + return; + } + } + publish(rest); + } + return { + snapshot: () => state, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + async start( + steps: readonly SetupStep[], + then: Resting = { status: "done" }, + ) { + if (state.status === "running" || state.status === "done") return; + failed = undefined; + await run(steps, then); + }, + /** Runs the failed step again, then the rest, resting where it would have. */ + async retry() { + const from = state.status === "failed" ? failed : undefined; + if (!from) return; + failed = undefined; + await run(from.steps, from.rest); + }, + reset() { + if (state.status === "running") return; + failed = undefined; + publish({ status: "idle" }); + }, + }; +} +export type Setup = ReturnType;