Switch away from nonboard for nostr login

This commit is contained in:
Jon Staab
2026-02-26 08:57:47 -08:00
parent 4e4d5907bf
commit 58d3036d31
7 changed files with 368 additions and 46 deletions
+1 -1
View File
@@ -19,8 +19,8 @@
"applesauce-relay": "^5.1.0",
"applesauce-signers": "^5.1.0",
"applesauce-wallet-connect": "^5.0.1",
"nonboard": "^1.0.8",
"preline": "^4.1.1",
"qr-scanner": "^1.4.2",
"solid-js": "^1.9.10",
"tailwindcss": "^4.2.1"
},
+14
View File
@@ -1,8 +1,22 @@
import type { IStaticMethods } from "preline"
declare global {
interface NostrNip07 {
getPublicKey(): Promise<string>
signEvent(event: unknown): Promise<unknown>
nip04?: {
encrypt(pubkey: string, plaintext: string): Promise<string>
decrypt(pubkey: string, ciphertext: string): Promise<string>
}
nip44?: {
encrypt(pubkey: string, plaintext: string): Promise<string>
decrypt(pubkey: string, ciphertext: string): Promise<string>
}
}
interface Window {
HSStaticMethods: IStaticMethods
nostr?: NostrNip07
}
}
-1
View File
@@ -1,7 +1,6 @@
@import "tailwindcss";
@import "../node_modules/preline/variants.css";
@source "../node_modules/preline/dist/*.js";
@import "nonboard/style.css";
/* Pointer cursor on buttons */
@layer base {
+2
View File
@@ -2,9 +2,11 @@
import { render } from "solid-js/web"
import "./index.css"
import App from "./App"
import { restoreAccounts } from "./lib/nostr"
const root = document.getElementById("root")!
import("preline").then(() => {
restoreAccounts()
render(() => <App />, root)
})
+43
View File
@@ -1,9 +1,52 @@
import { EventStore } from "applesauce-core"
import { RelayPool } from "applesauce-relay"
import { AccountManager } from "applesauce-accounts"
import type { IAccount, SerializedAccount } from "applesauce-accounts"
import { registerCommonAccountTypes } from "applesauce-accounts/accounts"
import { NostrConnectSigner } from "applesauce-signers"
export const API_URL = import.meta.env.VITE_API_URL
export const eventStore = new EventStore()
export const pool = new RelayPool()
export const accounts = new AccountManager()
const ACCOUNTS_STORAGE_KEY = "caravel.accounts"
const ACTIVE_ACCOUNT_STORAGE_KEY = "caravel.activeAccount"
registerCommonAccountTypes(accounts)
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
NostrConnectSigner.publishMethod = pool.publish.bind(pool)
export function restoreAccounts() {
const raw = localStorage.getItem(ACCOUNTS_STORAGE_KEY)
if (raw) {
try {
const saved = JSON.parse(raw) as SerializedAccount[]
accounts.fromJSON(saved, true)
} catch (error) {
console.warn("Failed to restore accounts", error)
}
}
const activeId = localStorage.getItem(ACTIVE_ACCOUNT_STORAGE_KEY)
if (activeId && accounts.getAccount(activeId)) {
accounts.setActive(activeId)
}
}
export function activateAccount(account: IAccount) {
accounts.addAccount(account)
accounts.setActive(account)
persistAccounts()
}
export function persistAccounts() {
localStorage.setItem(ACCOUNTS_STORAGE_KEY, JSON.stringify(accounts.toJSON(true)))
const active = accounts.getActive()
if (active) {
localStorage.setItem(ACTIVE_ACCOUNT_STORAGE_KEY, active.id)
} else {
localStorage.removeItem(ACTIVE_ACCOUNT_STORAGE_KEY)
}
}
-26
View File
@@ -1,26 +0,0 @@
declare module "nonboard" {
interface NonboardPayload {
pubkey: string
method: string
events: Array<{ kind: number; content: string; tags: string[][] }>
nip01?: { secret: string }
nip46?: { relays: string[]; clientSecret: string; signerPubkey: string }
}
interface NonboardOptions {
appUrl: string
appName: string
appImage?: string
onLogin?: (payload: NonboardPayload) => void
onSignup?: (payload: NonboardPayload) => void
onError?: (error: unknown) => void
onInfo?: (message: unknown) => void
}
interface NonboardApp {
mount(el: HTMLElement): void
destroy(): void
}
export default function nb(options: NonboardOptions): NonboardApp
}
+308 -18
View File
@@ -1,23 +1,179 @@
import { onMount, onCleanup } from "solid-js"
import { Show, createSignal, onCleanup, onMount } from "solid-js"
import { useNavigate } from "@solidjs/router"
import nb from "nonboard"
import { ExtensionAccount, NostrConnectAccount, PasswordAccount, PrivateKeyAccount } from "applesauce-accounts/accounts"
import { PasswordSigner } from "applesauce-signers"
import QrScanner from "qr-scanner"
import { activateAccount } from "../lib/nostr"
const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc']
type Tab = "nip07" | "nip46" | "key"
function normalizeBunkerUrl(value: string): string {
const trimmed = value.trim()
if (!trimmed) return ""
if (trimmed.startsWith("nostrconnect://")) {
const url = new URL(trimmed)
const remote = url.host || url.pathname.replace(/^\/+/, "")
const relays = url.searchParams.getAll("relay")
const secret = url.searchParams.get("secret")
const params = new URLSearchParams()
for (const relay of relays) params.append("relay", relay)
if (secret) params.set("secret", secret)
return `bunker://${remote}?${params.toString()}`
}
return trimmed
}
async function loadNostrConnectSigner() {
return import("applesauce-signers").then((m) => m.NostrConnectSigner)
}
export default function Login() {
const navigate = useNavigate()
let container: HTMLDivElement | undefined
const [tab, setTab] = createSignal<Tab>(window.nostr ? "nip07" : "nip46")
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal("")
onMount(() => {
const app = nb({
appUrl: window.location.origin,
appName: "Nostr Relay Hosting",
onLogin: () => navigate("/relays"),
onSignup: () => navigate("/relays"),
onError: (e: unknown) => console.error("Login error:", e),
onInfo: (msg: unknown) => console.info("Login info:", msg),
})
const [nostrConnectUri, setNostrConnectUri] = createSignal("")
const [showScanner, setShowScanner] = createSignal(false)
const [bunkerUrl, setBunkerUrl] = createSignal("")
const [nsecValue, setNsecValue] = createSignal("")
const [ncryptsecValue, setNcryptsecValue] = createSignal("")
const [password, setPassword] = createSignal("")
app.mount(container!)
onCleanup(() => app.destroy())
let scannerVideo: HTMLVideoElement | undefined
let scanner: QrScanner | undefined
let abortController: AbortController | undefined
async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) {
activateAccount(account)
navigate("/relays")
}
async function loginWithNip07() {
setError("")
setLoading(true)
try {
await completeLogin(await ExtensionAccount.fromExtension())
} catch (e) {
console.error("NIP-07 login failed", e)
setError(e instanceof Error ? e.message : "Failed to login with extension")
} finally {
setLoading(false)
}
}
async function startNostrConnect() {
setError("")
setLoading(true)
try {
const NostrConnectSigner = await loadNostrConnectSigner()
const signer = new NostrConnectSigner({ relays: NIP46_RELAYS })
const uri = signer.getNostrConnectURI({
name: "Caravel",
url: window.location.origin,
})
setNostrConnectUri(uri)
abortController = new AbortController()
const timeout = window.setTimeout(() => abortController?.abort(), 60_000)
try {
await signer.waitForSigner(abortController.signal)
const pubkey = await signer.getPublicKey()
const account = new NostrConnectAccount(pubkey, signer)
await completeLogin(account)
} finally {
window.clearTimeout(timeout)
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to connect signer")
} finally {
setLoading(false)
}
}
async function loginWithBunker() {
setError("")
setLoading(true)
try {
const uri = normalizeBunkerUrl(bunkerUrl())
const NostrConnectSigner = await loadNostrConnectSigner()
const signer = await NostrConnectSigner.fromBunkerURI(uri)
const pubkey = await signer.getPublicKey()
const account = new NostrConnectAccount(pubkey, signer)
await completeLogin(account)
} catch (e) {
setError(e instanceof Error ? e.message : "Invalid bunker URL")
} finally {
setLoading(false)
}
}
async function loginWithKeyMaterial() {
setError("")
setLoading(true)
try {
if (ncryptsecValue().trim()) {
if (!password().trim()) {
throw new Error("Password is required for ncryptsec")
}
const signer = await PasswordSigner.fromNcryptsec(ncryptsecValue().trim(), password())
const pubkey = await signer.getPublicKey()
const account = new PasswordAccount(pubkey, signer)
await completeLogin(account)
return
}
const key = nsecValue().trim()
if (!key) throw new Error("Enter an nsec or ncryptsec key")
const account = PrivateKeyAccount.fromKey(key)
await completeLogin(account)
} catch (e) {
setError(e instanceof Error ? e.message : "Invalid key")
} finally {
setLoading(false)
}
}
async function toggleScanner() {
const next = !showScanner()
setShowScanner(next)
if (!next) {
scanner?.destroy()
scanner = undefined
return
}
setError("")
if (!scannerVideo) return
scanner = new QrScanner(
scannerVideo,
(result) => {
setBunkerUrl(result.data)
setShowScanner(false)
scanner?.destroy()
scanner = undefined
},
{ returnDetailedScanResult: true },
)
try {
await scanner.start()
} catch (e) {
setError(e instanceof Error ? e.message : "Unable to access camera")
setShowScanner(false)
scanner?.destroy()
scanner = undefined
}
}
onCleanup(() => {
abortController?.abort()
scanner?.destroy()
})
return (
@@ -52,12 +208,146 @@ export default function Login() {
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-6 shadow-sm">
<h2 class="text-lg font-semibold text-gray-900">Log in with Nostr</h2>
<div class="rounded-xl border border-gray-200 bg-white/80 p-6">
<h2 class="text-lg font-semibold text-gray-900">Log in / Sign up</h2>
<p class="mt-2 text-xs text-gray-500">
New here? Create an account in seconds using your favorite Nostr signer.
Use any Nostr signer method. New users are automatically onboarded.
</p>
<div class="mt-6" ref={container} />
<div class="mt-6 space-y-4">
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${tab() === "nip07" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => setTab("nip07")}
disabled={!window.nostr}
>
NIP-07
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${tab() === "nip46" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => setTab("nip46")}
>
NIP-46
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${tab() === "key" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => setTab("key")}
>
Key
</button>
</div>
<Show when={tab() === "nip07"}>
<div class="space-y-3">
<p class="text-xs text-gray-500">
{window.nostr
? "Detected browser extension signer."
: "No extension detected in this browser."}
</p>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={!window.nostr || loading()}
onClick={loginWithNip07}
>
{loading() ? "Connecting..." : "Continue with extension"}
</button>
</div>
</Show>
<Show when={tab() === "nip46"}>
<div class="space-y-3">
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={loading()}
onClick={startNostrConnect}
>
Generate nostrconnect QR
</button>
<Show when={nostrConnectUri()}>
<a
href={nostrConnectUri()}
class="block rounded-lg border border-gray-200 p-3 text-xs text-gray-600 break-all"
target="_parent"
>
{nostrConnectUri()}
</a>
</Show>
<input
value={bunkerUrl()}
onInput={(e) => setBunkerUrl(e.currentTarget.value)}
placeholder="bunker://... or nostrconnect://..."
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<div class="flex gap-2">
<button
type="button"
class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={loading() || !bunkerUrl().trim()}
onClick={loginWithBunker}
>
Connect bunker URL
</button>
<button
type="button"
class="rounded-lg border border-gray-300 px-3 py-2 text-sm"
onClick={toggleScanner}
>
{showScanner() ? "Stop" : "Scan"}
</button>
</div>
<Show when={showScanner()}>
<video ref={scannerVideo} class="w-full rounded-lg border border-gray-200" />
</Show>
</div>
</Show>
<Show when={tab() === "key"}>
<form
class="space-y-3"
onSubmit={(e) => {
e.preventDefault()
void loginWithKeyMaterial()
}}
>
<input
value={nsecValue()}
onInput={(e) => setNsecValue(e.currentTarget.value)}
placeholder="nsec1..."
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<input
value={ncryptsecValue()}
onInput={(e) => setNcryptsecValue(e.currentTarget.value)}
placeholder="ncryptsec1..."
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<input
type="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
placeholder="Password (for ncryptsec)"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<button
type="submit"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={loading()}
>
Continue with key
</button>
</form>
</Show>
<Show when={error()}>
<p class="text-xs text-red-600">{error()}</p>
</Show>
</div>
<p class="mt-6 text-xs text-gray-500">
By continuing, you agree to use NIP-98 authentication for secure access.
</p>