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:
- 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.

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.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)

View file

@ -14,7 +14,10 @@
const apply = () => $healthChecks.apply(healthCheck)
</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">
<strong class="text-sm">{healthCheck.title}</strong>
<p class="text-sm opacity-75">{healthCheck.description}</p>

View file

@ -31,7 +31,7 @@
</p>
{:else}
<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} />
{/each}
{#if $pending.length > 1}

View file

@ -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 @@
<Modal tag="form" onsubmit={preventDefault(next)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Your Keys are Ready!</ModalTitle>
<ModalTitle>Backup your Key</ModalTitle>
</ModalHeader>
<p>
A cryptographic key pair has two parts: your <strong>public key</strong> identifies your

View file

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

View file

@ -41,8 +41,8 @@
<ModalTitle>You're all set!</ModalTitle>
</ModalHeader>
<p>
You've created your profile, saved your keys, and now you're ready to start chatting — all
without asking permission!
You've created your profile, and now you're ready to start chatting — all without asking
permission!
</p>
<p>
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 {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<Record<string, boolean>>({})
export class HealthChecks {
context: Projection<HealthCheckContext>
pending: Projection<HealthCheck[]>
@ -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())
}

View file

@ -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