diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index 6bc9f7f8..efd70061 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -48,12 +48,16 @@ key in the app, so that I can start participating without an external tool. Acceptance: -- Entering a display name advances to a key-backup step whose "Continue" button - stays disabled until a key file has been downloaded. -- Choosing the encrypted download requires a password of at least 12 characters - and produces a file containing an ncryptsec rather than a plain nsec. +- Entering a display name advances directly to the completion step without showing + the key-backup modal. - Finishing the flow logs the new user in, dismisses the dialog, and lands on the dashboard with the platform's default space visible. +- The dashboard shows a pending "Back Up Your Key" health check until the existing + backup flow completes successfully; leaving the modal keeps the check pending. +- Reloading preserves both the pending reminder and a completed backup. +- Applying all recommendations opens the backup flow alongside the automatic relay fixes. +- Choosing the encrypted download requires a password of at least 12 characters + and produces a file containing an ncryptsec rather than a plain nsec. - The display name entered during signup appears on the new user's own profile. ### US-003 — Log in with an existing private key @@ -68,6 +72,7 @@ Acceptance: - Pasting an ncryptsec reveals a password field: the correct password logs in, a wrong one shows an error and stays logged out. - Text that is not a valid key leaves the submit button disabled. +- Logging in with an existing private key does not enable the "Back Up Your Key" health check. - bob logging in with his own key in a separate context sees his own identity, not alice's. diff --git a/e2e/specs/onboarding.spec.ts b/e2e/specs/onboarding.spec.ts index 1ac3ab7a..bb8ace0f 100644 --- a/e2e/specs/onboarding.spec.ts +++ b/e2e/specs/onboarding.spec.ts @@ -100,36 +100,7 @@ test("US-002 sign up by generating a new key", async ({seed, visit}) => { await page.locator('input[type="text"]').fill("Nova Tester") await page.getByRole("button", {name: "Create Account"}).click() - await expect(page.getByRole("heading", {name: "Your Keys are Ready!"})).toBeVisible() - - const continueButton = page.getByRole("button", {name: "Continue"}) - const password = page.locator('input[type="password"]') - - await expect(continueButton).toBeDisabled() - - await page.getByRole("button", {name: "I want to download an encrypted version"}).click() - await password.fill("hunter2") - await page.getByRole("button", {name: "Download my key"}).click() - - await expect(page.getByRole("alert")).toContainText( - "Your password must be at least 12 characters long.", - ) - await expect(continueButton).toBeDisabled() - - await password.fill("correct horse battery staple") - - const download = page.waitForEvent("download") - - await page.getByRole("button", {name: "Download my key"}).click() - - const contents = await readFile(await (await download).path(), "utf8") - - expect(contents).toContain("ncryptsec1") - expect(contents).not.toContain("nsec1") - - await expect(continueButton).toBeEnabled() - await continueButton.click() - + await expect(page.getByRole("heading", {name: "Backup your Key"})).toHaveCount(0) await expect(page.getByRole("heading", {name: "You're all set!"})).toBeVisible() await page.getByRole("button", {name: "Go to Dashboard"}).click() @@ -138,6 +109,7 @@ test("US-002 sign up by generating a new key", async ({seed, visit}) => { await expect(page.locator(".primary-nav")).toBeVisible() await expect(page.getByRole("heading", {name: "You're all set!"})).toHaveCount(0) await expect(gate(page)).toHaveCount(0) + await expect(page.getByText("Back Up Your Key")).toBeVisible() // A space's nav item is a button rather than a link — PrimaryNavItemSpace passes an onclick, and // PrimaryNavItem renders a Button whenever it has one — so it is addressed by the tooltip it @@ -151,6 +123,58 @@ test("US-002 sign up by generating a new key", async ({seed, visit}) => { await openSettings(page) await expect(page.getByText("Nova Tester")).toBeVisible() + + await page.goto("/home") + await expect(page.getByText("Back Up Your Key")).toBeVisible() + + await page.reload() + + await expect(page.getByText("Back Up Your Key")).toBeVisible() + + const backupCheck = page.getByRole("group", {name: "Back Up Your Key"}) + + await page.getByRole("button", {name: "Apply all recommendations"}).click() + await expect(page.getByRole("heading", {name: "Backup your Key"})).toBeVisible() + await page.getByRole("button", {name: "Go back"}).click() + await expect(backupCheck).toBeVisible() + + await backupCheck.getByRole("button", {name: "Back Up"}).click() + await expect(page.getByRole("heading", {name: "Backup your Key"})).toBeVisible() + await page.getByRole("button", {name: "Go back"}).click() + await expect(page.getByText("Back Up Your Key")).toBeVisible() + + await backupCheck.getByRole("button", {name: "Back Up"}).click() + + const doneButton = page.getByRole("button", {name: "Done"}) + const password = page.locator('input[type="password"]') + + await page.getByRole("button", {name: "I want to download an encrypted version"}).click() + await password.fill("hunter2") + await page.getByRole("button", {name: "Download my key"}).click() + + await expect(page.getByRole("alert")).toContainText( + "Your password must be at least 12 characters long.", + ) + await expect(doneButton).toBeDisabled() + + await password.fill("correct horse battery staple") + + const download = page.waitForEvent("download") + + await page.getByRole("button", {name: "Download my key"}).click() + + const contents = await readFile(await (await download).path(), "utf8") + + expect(contents).toContain("ncryptsec1") + expect(contents).not.toContain("nsec1") + + await expect(doneButton).toBeEnabled() + await doneButton.click() + + await expect(page.getByText("Back Up Your Key")).toHaveCount(0) + await page.reload() + await expect(page.locator(".primary-nav")).toBeVisible() + await expect(page.getByText("Back Up Your Key")).toHaveCount(0) }) test("US-003 log in with an existing private key", async ({seed, visit}) => { @@ -166,6 +190,7 @@ test("US-003 log in with an existing private key", async ({seed, visit}) => { await logInWithKey(withNsec, nsecFor(users.alice)) await expect(withNsec.locator(".primary-nav")).toBeVisible() + await expect(withNsec.getByText("Back Up Your Key")).toHaveCount(0) await openSettings(withNsec) await expect(npubField(withNsec)).toHaveValue(aliceNpub) @@ -173,6 +198,7 @@ test("US-003 log in with an existing private key", async ({seed, visit}) => { await logInWithKey(withHex, users.alice.secret) await expect(withHex.locator(".primary-nav")).toBeVisible() + await expect(withHex.getByText("Back Up Your Key")).toHaveCount(0) await openSettings(withHex) await expect(npubField(withHex)).toHaveValue(aliceNpub) @@ -201,6 +227,7 @@ test("US-003 log in with an existing private key", async ({seed, visit}) => { await submit.click() await expect(withNcryptsec.locator(".primary-nav")).toBeVisible() + await expect(withNcryptsec.getByText("Back Up Your Key")).toHaveCount(0) await openSettings(withNcryptsec) await expect(npubField(withNcryptsec)).toHaveValue(aliceNpub) diff --git a/src/app/components/HealthCheckItem.svelte b/src/app/components/HealthCheckItem.svelte index c1956444..01f3fa26 100644 --- a/src/app/components/HealthCheckItem.svelte +++ b/src/app/components/HealthCheckItem.svelte @@ -14,7 +14,10 @@ const apply = () => $healthChecks.apply(healthCheck) -
+
{healthCheck.title}

{healthCheck.description}

diff --git a/src/app/components/HomeHealthChecks.svelte b/src/app/components/HomeHealthChecks.svelte index f138a3a1..653f7c4b 100644 --- a/src/app/components/HomeHealthChecks.svelte +++ b/src/app/components/HomeHealthChecks.svelte @@ -31,7 +31,7 @@

{:else}
- {#each $pending as healthCheck (healthCheck.title)} + {#each $pending as healthCheck (healthCheck.id)} {/each} {#if $pending.length > 1} diff --git a/src/app/components/KeyDownload.svelte b/src/app/components/KeyDownload.svelte index b30c0ea5..340daff8 100644 --- a/src/app/components/KeyDownload.svelte +++ b/src/app/components/KeyDownload.svelte @@ -66,7 +66,7 @@ const ncryptsec = encrypt(hexToBytes(secret), password) instructions = ` This file contains a backup of your Nostr secret key, downloaded from ${PLATFORM_NAME} and encrypted using - a password you chose when you signed up. + a password you chose when you backed up your key. ${sharedCopy} @@ -95,15 +95,15 @@ try { await downloadText("Nostr Secret Key.txt", cleanupCopy(instructions)) + didDownload = true } catch (e) { // Dismissing the native share sheet rejects with "Share canceled" — leave // the flow gated so the user can try again rather than showing an error. if (!errorMessage(e).toLowerCase().includes("cancel")) { + console.error(e) pushToast({theme: "error", message: "We couldn't save your key. Please try again."}) } } - - didDownload = true } const onPasswordChange = () => { @@ -123,7 +123,7 @@ - Your Keys are Ready! + Backup your Key

A cryptographic key pair has two parts: your public key identifies your diff --git a/src/app/components/SignUp.svelte b/src/app/components/SignUp.svelte index 5676c640..e66a7832 100644 --- a/src/app/components/SignUp.svelte +++ b/src/app/components/SignUp.svelte @@ -1,6 +1,7 @@ - - diff --git a/src/app/healthChecks.ts b/src/app/healthChecks.ts index 40f97cbf..8a131b4d 100644 --- a/src/app/healthChecks.ts +++ b/src/app/healthChecks.ts @@ -1,17 +1,18 @@ -import {derived} from "svelte/store" -import {sample} from "@welshman/lib" +import {derived, get, writable} from "svelte/store" +import {assoc, sample} from "@welshman/lib" import { MessagingRelayLists, RelayLists, Relays, SearchRelayLists, - projectFrom, projection, publish, } from "@welshman/app" import type {IApp, Projection} from "@welshman/app" -import {usePlugin} from "@app/core" +import KeyDownload from "@app/components/KeyDownload.svelte" +import {session, usePlugin} from "@app/core" import {DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/env" +import {pushModal} from "@app/modal" export type HealthCheckContext = { readRelays: string[] @@ -21,6 +22,7 @@ export type HealthCheckContext = { } export type HealthCheck = { + id: string title: string description: string action: string @@ -28,6 +30,8 @@ export type HealthCheck = { apply: (context: HealthCheckContext) => unknown } +export const forceHealthChecks = writable>({}) + export class HealthChecks { context: Projection pending: Projection @@ -55,8 +59,10 @@ export class HealthChecks { ), ) - this.pending = projectFrom(this.context, $context => - this.checks.filter(check => check.isPending($context)), + this.pending = projection( + derived([this.context.$, forceHealthChecks], ([$context, $forceHealthChecks]) => + this.checks.filter(check => $forceHealthChecks[check.id] ?? check.isPending($context)), + ), ) } @@ -64,6 +70,7 @@ export class HealthChecks { private checks: HealthCheck[] = [ { + id: "missing-inbox-relays", title: "Missing Inbox Relays", description: "Other people aren't currently able to reliably tag you in public notes.", action: "Update", @@ -71,6 +78,7 @@ export class HealthChecks { apply: () => this.app.use(RelayLists).setReadUrls(DEFAULT_RELAYS).then(publish), }, { + id: "missing-outbox-relays", title: "Missing Outbox Relays", description: "Other people aren't currently able to reliably find your public notes.", action: "Update", @@ -78,6 +86,7 @@ export class HealthChecks { apply: () => this.app.use(RelayLists).setWriteUrls(DEFAULT_RELAYS).then(publish), }, { + id: "missing-dm-relays", title: "Missing DM Relays", description: "You aren't currently able to reliably send or receive direct messages.", action: "Update", @@ -86,6 +95,7 @@ export class HealthChecks { this.app.use(MessagingRelayLists).setUrls(DEFAULT_MESSAGING_RELAYS).then(publish), }, { + id: "too-many-inbox-relays", title: "Too Many Inbox Relays", description: "You have more inbox relays than is really necessary, which can affect resource usage.", @@ -95,6 +105,7 @@ export class HealthChecks { this.app.use(RelayLists).setReadUrls(sample(5, context.readRelays)).then(publish), }, { + id: "too-many-outbox-relays", title: "Too Many Outbox Relays", description: "You have more outbox relays than is really necessary, which can affect resource usage.", @@ -104,6 +115,7 @@ export class HealthChecks { this.app.use(RelayLists).setWriteUrls(sample(5, context.writeRelays)).then(publish), }, { + id: "too-many-dm-relays", title: "Too Many DM Relays", description: "You have more DM relays than is really necessary, which can affect resource usage.", @@ -113,6 +125,7 @@ export class HealthChecks { this.app.use(MessagingRelayLists).setUrls(sample(5, context.messagingRelays)).then(publish), }, { + id: "invalid-search-relays", title: "Invalid Search Relays", description: "Some of your search relays don't support search.", action: "Remove Invalid", @@ -123,9 +136,29 @@ export class HealthChecks { .setUrls(context.searchRelays.filter(this.supportsSearch)) .then(publish), }, + { + id: "backup-key", + title: "Back Up Your Key", + description: "Save a backup of your private key in a secure place.", + action: "Back Up", + isPending: () => false, + apply: () => { + const {secret} = session.get()!.data as {secret: string} + + pushModal(KeyDownload, { + secret, + next: () => { + forceHealthChecks.update(assoc("backup-key", false)) + history.back() + }, + submitText: "Done", + }) + }, + }, ] - isPending = (healthCheck: HealthCheck) => healthCheck.isPending(this.context.get()) + isPending = (healthCheck: HealthCheck) => + get(forceHealthChecks)[healthCheck.id] ?? healthCheck.isPending(this.context.get()) apply = (healthCheck: HealthCheck) => healthCheck.apply(this.context.get()) } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 20af578d..2014a09b 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -33,6 +33,7 @@ import {kv, ss, storage} from "@app/storage" import {device} from "@app/device" import {userSettingsValues, notificationSettings} from "@app/settings" + import {forceHealthChecks} from "@app/healthChecks" import {setupShareIntents, shareFromNative} from "@app/share" import {shouldUnwrap, syncApplicationData} from "@app/sync" import * as env from "@app/env" @@ -194,6 +195,11 @@ store: pushState, storage: kv, }), + sync({ + key: "forceHealthChecks", + store: forceHealthChecks, + storage: kv, + }), ]) // Wait for critical storage data only