fix: move generated key backup to home health checks (#587)

This commit is contained in:
Gaurav Chaudhary 2026-09-22 17:57:47 +00:00 committed by hodlbod
parent a50b0160bd
commit 929b6e4e41
10 changed files with 128 additions and 69 deletions

View file

@ -48,12 +48,16 @@ key in the app, so that I can start participating without an external tool.
Acceptance: Acceptance:
- Entering a display name advances to a key-backup step whose "Continue" button - Entering a display name advances directly to the completion step without showing
stays disabled until a key file has been downloaded. the key-backup modal.
- Choosing the encrypted download requires a password of at least 12 characters
and produces a file containing an ncryptsec rather than a plain nsec.
- Finishing the flow logs the new user in, dismisses the dialog, and lands on - 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 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. - The display name entered during signup appears on the new user's own profile.
### US-003 — Log in with an existing private key ### 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 - Pasting an ncryptsec reveals a password field: the correct password logs in, a
wrong one shows an error and stays logged out. wrong one shows an error and stays logged out.
- Text that is not a valid key leaves the submit button disabled. - 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, - bob logging in with his own key in a separate context sees his own identity,
not alice's. not alice's.

View file

@ -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.locator('input[type="text"]').fill("Nova Tester")
await page.getByRole("button", {name: "Create Account"}).click() await page.getByRole("button", {name: "Create Account"}).click()
await expect(page.getByRole("heading", {name: "Your Keys are Ready!"})).toBeVisible() await expect(page.getByRole("heading", {name: "Backup your Key"})).toHaveCount(0)
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: "You're all set!"})).toBeVisible() await expect(page.getByRole("heading", {name: "You're all set!"})).toBeVisible()
await page.getByRole("button", {name: "Go to Dashboard"}).click() 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.locator(".primary-nav")).toBeVisible()
await expect(page.getByRole("heading", {name: "You're all set!"})).toHaveCount(0) await expect(page.getByRole("heading", {name: "You're all set!"})).toHaveCount(0)
await expect(gate(page)).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 // 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 // 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 openSettings(page)
await expect(page.getByText("Nova Tester")).toBeVisible() 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}) => { 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 logInWithKey(withNsec, nsecFor(users.alice))
await expect(withNsec.locator(".primary-nav")).toBeVisible() await expect(withNsec.locator(".primary-nav")).toBeVisible()
await expect(withNsec.getByText("Back Up Your Key")).toHaveCount(0)
await openSettings(withNsec) await openSettings(withNsec)
await expect(npubField(withNsec)).toHaveValue(aliceNpub) 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 logInWithKey(withHex, users.alice.secret)
await expect(withHex.locator(".primary-nav")).toBeVisible() await expect(withHex.locator(".primary-nav")).toBeVisible()
await expect(withHex.getByText("Back Up Your Key")).toHaveCount(0)
await openSettings(withHex) await openSettings(withHex)
await expect(npubField(withHex)).toHaveValue(aliceNpub) 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 submit.click()
await expect(withNcryptsec.locator(".primary-nav")).toBeVisible() await expect(withNcryptsec.locator(".primary-nav")).toBeVisible()
await expect(withNcryptsec.getByText("Back Up Your Key")).toHaveCount(0)
await openSettings(withNcryptsec) await openSettings(withNcryptsec)
await expect(npubField(withNcryptsec)).toHaveValue(aliceNpub) await expect(npubField(withNcryptsec)).toHaveValue(aliceNpub)

View file

@ -14,7 +14,10 @@
const apply = () => $healthChecks.apply(healthCheck) const apply = () => $healthChecks.apply(healthCheck)
</script> </script>
<div class="flex items-start justify-between gap-3 px-4 py-3"> <div
role="group"
aria-label={healthCheck.title}
class="flex items-start justify-between gap-3 px-4 py-3">
<div class="flex min-w-0 flex-col gap-1"> <div class="flex min-w-0 flex-col gap-1">
<strong class="text-sm">{healthCheck.title}</strong> <strong class="text-sm">{healthCheck.title}</strong>
<p class="text-sm opacity-75">{healthCheck.description}</p> <p class="text-sm opacity-75">{healthCheck.description}</p>

View file

@ -31,7 +31,7 @@
</p> </p>
{:else} {:else}
<div class="flex flex-col divide-y divide-line border-t border-line"> <div class="flex flex-col divide-y divide-line border-t border-line">
{#each $pending as healthCheck (healthCheck.title)} {#each $pending as healthCheck (healthCheck.id)}
<HealthCheckItem {healthCheck} /> <HealthCheckItem {healthCheck} />
{/each} {/each}
{#if $pending.length > 1} {#if $pending.length > 1}

View file

@ -66,7 +66,7 @@
const ncryptsec = encrypt(hexToBytes(secret), password) const ncryptsec = encrypt(hexToBytes(secret), password)
instructions = ` instructions = `
This file contains a backup of your Nostr secret key, downloaded from ${PLATFORM_NAME} and encrypted using 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} ${sharedCopy}
@ -95,15 +95,15 @@
try { try {
await downloadText("Nostr Secret Key.txt", cleanupCopy(instructions)) await downloadText("Nostr Secret Key.txt", cleanupCopy(instructions))
didDownload = true
} catch (e) { } catch (e) {
// Dismissing the native share sheet rejects with "Share canceled" — leave // Dismissing the native share sheet rejects with "Share canceled" — leave
// the flow gated so the user can try again rather than showing an error. // the flow gated so the user can try again rather than showing an error.
if (!errorMessage(e).toLowerCase().includes("cancel")) { if (!errorMessage(e).toLowerCase().includes("cancel")) {
console.error(e)
pushToast({theme: "error", message: "We couldn't save your key. Please try again."}) pushToast({theme: "error", message: "We couldn't save your key. Please try again."})
} }
} }
didDownload = true
} }
const onPasswordChange = () => { const onPasswordChange = () => {
@ -123,7 +123,7 @@
<Modal tag="form" onsubmit={preventDefault(next)}> <Modal tag="form" onsubmit={preventDefault(next)}>
<ModalBody> <ModalBody>
<ModalHeader> <ModalHeader>
<ModalTitle>Your Keys are Ready!</ModalTitle> <ModalTitle>Backup your Key</ModalTitle>
</ModalHeader> </ModalHeader>
<p> <p>
A cryptographic key pair has two parts: your <strong>public key</strong> identifies your A cryptographic key pair has two parts: your <strong>public key</strong> identifies your

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import type {ClientOptions} from "@pomade/core" import type {ClientOptions} from "@pomade/core"
import {assoc} from "@welshman/lib"
import {makeSecret, RELAYS, MESSAGING_RELAYS, makeEvent} from "@welshman/util" import {makeSecret, RELAYS, MESSAGING_RELAYS, makeEvent} from "@welshman/util"
import {Profile} from "@welshman/domain" import {Profile} from "@welshman/domain"
import {Thunks, nip01, publish, toSession} from "@welshman/app" import {Thunks, nip01, publish, toSession} from "@welshman/app"
@ -13,7 +14,6 @@
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import LogIn from "@app/components/LogIn.svelte" import LogIn from "@app/components/LogIn.svelte"
import SignUpKey from "@app/components/SignUpKey.svelte"
import SignUpEmail from "@app/components/SignUpEmail.svelte" import SignUpEmail from "@app/components/SignUpEmail.svelte"
import SignUpProfile from "@app/components/SignUpProfile.svelte" import SignUpProfile from "@app/components/SignUpProfile.svelte"
import type {ProfileValues} from "@app/components/ProfileEditForm.svelte" import type {ProfileValues} from "@app/components/ProfileEditForm.svelte"
@ -27,6 +27,7 @@
DEFAULT_SPACES, DEFAULT_SPACES,
} from "@app/env" } from "@app/env"
import {setChecked} from "@app/notifications" import {setChecked} from "@app/notifications"
import {forceHealthChecks} from "@app/healthChecks"
import {loginWithPomade} from "@app/pomade" import {loginWithPomade} from "@app/pomade"
import {pushModal, clearModals} from "@app/modal" import {pushModal, clearModals} from "@app/modal"
import {app, domain, login, roomLists} from "@app/core" import {app, domain, login, roomLists} from "@app/core"
@ -89,14 +90,14 @@
}, },
}, },
nostr: { nostr: {
start: () => pushModal(SignUpProfile, {next: flows.nostr.key, step: 1, totalSteps: 3}), start: () => pushModal(SignUpProfile, {next: flows.nostr.complete, step: 1, totalSteps: 2}),
key: () => pushModal(SignUpKey, {next: flows.nostr.complete, step: 2, totalSteps: 3}),
complete: () => complete: () =>
pushModal(SignUpComplete, {next: flows.nostr.finalize, step: 3, totalSteps: 3}), pushModal(SignUpComplete, {next: flows.nostr.finalize, step: 2, totalSteps: 2}),
finalize: async () => { finalize: async () => {
const secret = getKey<string>("signup.secret")! const secret = getKey<string>("signup.secret")!
await login(toSession(nip01, {secret})) await login(toSession(nip01, {secret}))
forceHealthChecks.update(assoc("backup-key", true))
await completeSignup() await completeSignup()
}, },
}, },

View file

@ -41,8 +41,8 @@
<ModalTitle>You're all set!</ModalTitle> <ModalTitle>You're all set!</ModalTitle>
</ModalHeader> </ModalHeader>
<p> <p>
You've created your profile, saved your keys, and now you're ready to start chatting — all You've created your profile, and now you're ready to start chatting — all without asking
without asking permission! permission!
</p> </p>
<p> <p>
From your dashboard, you can use invite links, discover community spaces, and keep up-to-date From your dashboard, you can use invite links, discover community spaces, and keep up-to-date

View file

@ -1,16 +0,0 @@
<script lang="ts">
import {getKey} from "@lib/implicit"
import KeyDownload from "@app/components/KeyDownload.svelte"
type Props = {
next: () => void
step?: number
totalSteps?: number
}
const {next, step, totalSteps}: Props = $props()
const secret = getKey<string>("signup.secret")!
</script>
<KeyDownload {secret} {next} {step} {totalSteps} />

View file

@ -1,17 +1,18 @@
import {derived} from "svelte/store" import {derived, get, writable} from "svelte/store"
import {sample} from "@welshman/lib" import {assoc, sample} from "@welshman/lib"
import { import {
MessagingRelayLists, MessagingRelayLists,
RelayLists, RelayLists,
Relays, Relays,
SearchRelayLists, SearchRelayLists,
projectFrom,
projection, projection,
publish, publish,
} from "@welshman/app" } from "@welshman/app"
import type {IApp, Projection} 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 {DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/env"
import {pushModal} from "@app/modal"
export type HealthCheckContext = { export type HealthCheckContext = {
readRelays: string[] readRelays: string[]
@ -21,6 +22,7 @@ export type HealthCheckContext = {
} }
export type HealthCheck = { export type HealthCheck = {
id: string
title: string title: string
description: string description: string
action: string action: string
@ -28,6 +30,8 @@ export type HealthCheck = {
apply: (context: HealthCheckContext) => unknown apply: (context: HealthCheckContext) => unknown
} }
export const forceHealthChecks = writable<Record<string, boolean>>({})
export class HealthChecks { export class HealthChecks {
context: Projection<HealthCheckContext> context: Projection<HealthCheckContext>
pending: Projection<HealthCheck[]> pending: Projection<HealthCheck[]>
@ -55,8 +59,10 @@ export class HealthChecks {
), ),
) )
this.pending = projectFrom(this.context, $context => this.pending = projection(
this.checks.filter(check => check.isPending($context)), 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[] = [ private checks: HealthCheck[] = [
{ {
id: "missing-inbox-relays",
title: "Missing Inbox Relays", title: "Missing Inbox Relays",
description: "Other people aren't currently able to reliably tag you in public notes.", description: "Other people aren't currently able to reliably tag you in public notes.",
action: "Update", action: "Update",
@ -71,6 +78,7 @@ export class HealthChecks {
apply: () => this.app.use(RelayLists).setReadUrls(DEFAULT_RELAYS).then(publish), apply: () => this.app.use(RelayLists).setReadUrls(DEFAULT_RELAYS).then(publish),
}, },
{ {
id: "missing-outbox-relays",
title: "Missing Outbox Relays", title: "Missing Outbox Relays",
description: "Other people aren't currently able to reliably find your public notes.", description: "Other people aren't currently able to reliably find your public notes.",
action: "Update", action: "Update",
@ -78,6 +86,7 @@ export class HealthChecks {
apply: () => this.app.use(RelayLists).setWriteUrls(DEFAULT_RELAYS).then(publish), apply: () => this.app.use(RelayLists).setWriteUrls(DEFAULT_RELAYS).then(publish),
}, },
{ {
id: "missing-dm-relays",
title: "Missing DM Relays", title: "Missing DM Relays",
description: "You aren't currently able to reliably send or receive direct messages.", description: "You aren't currently able to reliably send or receive direct messages.",
action: "Update", action: "Update",
@ -86,6 +95,7 @@ export class HealthChecks {
this.app.use(MessagingRelayLists).setUrls(DEFAULT_MESSAGING_RELAYS).then(publish), this.app.use(MessagingRelayLists).setUrls(DEFAULT_MESSAGING_RELAYS).then(publish),
}, },
{ {
id: "too-many-inbox-relays",
title: "Too Many Inbox Relays", title: "Too Many Inbox Relays",
description: description:
"You have more inbox relays than is really necessary, which can affect resource usage.", "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), this.app.use(RelayLists).setReadUrls(sample(5, context.readRelays)).then(publish),
}, },
{ {
id: "too-many-outbox-relays",
title: "Too Many Outbox Relays", title: "Too Many Outbox Relays",
description: description:
"You have more outbox relays than is really necessary, which can affect resource usage.", "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), this.app.use(RelayLists).setWriteUrls(sample(5, context.writeRelays)).then(publish),
}, },
{ {
id: "too-many-dm-relays",
title: "Too Many DM Relays", title: "Too Many DM Relays",
description: description:
"You have more DM relays than is really necessary, which can affect resource usage.", "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), this.app.use(MessagingRelayLists).setUrls(sample(5, context.messagingRelays)).then(publish),
}, },
{ {
id: "invalid-search-relays",
title: "Invalid Search Relays", title: "Invalid Search Relays",
description: "Some of your search relays don't support search.", description: "Some of your search relays don't support search.",
action: "Remove Invalid", action: "Remove Invalid",
@ -123,9 +136,29 @@ export class HealthChecks {
.setUrls(context.searchRelays.filter(this.supportsSearch)) .setUrls(context.searchRelays.filter(this.supportsSearch))
.then(publish), .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()) apply = (healthCheck: HealthCheck) => healthCheck.apply(this.context.get())
} }

View file

@ -33,6 +33,7 @@
import {kv, ss, storage} from "@app/storage" import {kv, ss, storage} from "@app/storage"
import {device} from "@app/device" import {device} from "@app/device"
import {userSettingsValues, notificationSettings} from "@app/settings" import {userSettingsValues, notificationSettings} from "@app/settings"
import {forceHealthChecks} from "@app/healthChecks"
import {setupShareIntents, shareFromNative} from "@app/share" import {setupShareIntents, shareFromNative} from "@app/share"
import {shouldUnwrap, syncApplicationData} from "@app/sync" import {shouldUnwrap, syncApplicationData} from "@app/sync"
import * as env from "@app/env" import * as env from "@app/env"
@ -194,6 +195,11 @@
store: pushState, store: pushState,
storage: kv, storage: kv,
}), }),
sync({
key: "forceHealthChecks",
store: forceHealthChecks,
storage: kv,
}),
]) ])
// Wait for critical storage data only // Wait for critical storage data only