From 58d3036d31232fc23d4297d8076d182c68d9722e Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 26 Feb 2026 08:57:47 -0800 Subject: [PATCH] Switch away from nonboard for nostr login --- frontend/package.json | 2 +- frontend/src/global.d.ts | 14 ++ frontend/src/index.css | 1 - frontend/src/index.tsx | 2 + frontend/src/lib/nostr.ts | 43 +++++ frontend/src/nonboard.d.ts | 26 --- frontend/src/pages/Login.tsx | 326 +++++++++++++++++++++++++++++++++-- 7 files changed, 368 insertions(+), 46 deletions(-) delete mode 100644 frontend/src/nonboard.d.ts diff --git a/frontend/package.json b/frontend/package.json index b9ac821..7f1bd70 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" }, diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts index af366cc..a266af7 100644 --- a/frontend/src/global.d.ts +++ b/frontend/src/global.d.ts @@ -1,8 +1,22 @@ import type { IStaticMethods } from "preline" declare global { + interface NostrNip07 { + getPublicKey(): Promise + signEvent(event: unknown): Promise + nip04?: { + encrypt(pubkey: string, plaintext: string): Promise + decrypt(pubkey: string, ciphertext: string): Promise + } + nip44?: { + encrypt(pubkey: string, plaintext: string): Promise + decrypt(pubkey: string, ciphertext: string): Promise + } + } + interface Window { HSStaticMethods: IStaticMethods + nostr?: NostrNip07 } } diff --git a/frontend/src/index.css b/frontend/src/index.css index 352506d..c6126f9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 { diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index d889ce4..5e7a444 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -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(() => , root) }) diff --git a/frontend/src/lib/nostr.ts b/frontend/src/lib/nostr.ts index 5494bb9..9de487f 100644 --- a/frontend/src/lib/nostr.ts +++ b/frontend/src/lib/nostr.ts @@ -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) + } +} diff --git a/frontend/src/nonboard.d.ts b/frontend/src/nonboard.d.ts deleted file mode 100644 index 05d2542..0000000 --- a/frontend/src/nonboard.d.ts +++ /dev/null @@ -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 -} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 83cad8a..9085174 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -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(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() { -
-

Log in with Nostr

+
+

Log in / Sign up

- New here? Create an account in seconds using your favorite Nostr signer. + Use any Nostr signer method. New users are automatically onboarded.

-
+
+
+ + + +
+ + +
+

+ {window.nostr + ? "Detected browser extension signer." + : "No extension detected in this browser."} +

+ +
+
+ + +
+ + + + + {nostrConnectUri()} + + + + setBunkerUrl(e.currentTarget.value)} + placeholder="bunker://... or nostrconnect://..." + class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> +
+ + +
+ + +
+
+ + +
{ + e.preventDefault() + void loginWithKeyMaterial() + }} + > + setNsecValue(e.currentTarget.value)} + placeholder="nsec1..." + class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> + setNcryptsecValue(e.currentTarget.value)} + placeholder="ncryptsec1..." + class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> + setPassword(e.currentTarget.value)} + placeholder="Password (for ncryptsec)" + class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> + +
+
+ + +

{error()}

+
+

By continuing, you agree to use NIP-98 authentication for secure access.