fix: move generated key backup to home health checks (#587)
This commit is contained in:
parent
a50b0160bd
commit
929b6e4e41
10 changed files with 128 additions and 69 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue