forked from coracle/caravel
Switch away from nonboard for nostr login
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
Vendored
+14
@@ -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,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,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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
-26
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user