Clean up login page

This commit is contained in:
Jon Staab
2026-02-26 14:15:07 -08:00
parent e9f56295de
commit 93e9a714cf
9 changed files with 321 additions and 225 deletions
+36 -9
View File
@@ -1,23 +1,50 @@
import { Show } from "solid-js"
import { A } from "@solidjs/router"
import { PLATFORM_NAME } from "../lib/nostr"
import { PLATFORM_NAME, useActiveAccount, useProfilePicture } from "../lib/nostr"
export default function Navbar() {
const account = useActiveAccount()
const picture = useProfilePicture(() => account()?.pubkey)
return (
<nav class="bg-white border-b border-gray-200">
<div class="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
<A href="/" class="font-bold text-gray-900 text-lg">
{PLATFORM_NAME}
<div class="max-w-4xl mx-auto px-4 h-14 flex items-center justify-between">
<A href="/" class="flex items-center gap-2">
<img src="/caravel.png" alt={PLATFORM_NAME} class="h-8 w-8 rounded-full object-cover" />
<span class="font-bold text-gray-900 text-lg">{PLATFORM_NAME}</span>
</A>
<div class="flex items-center gap-4">
<A href="/relays" class="text-sm text-gray-600 hover:text-gray-900">
Dashboard
</A>
<A
href="/login"
class="text-sm py-1.5 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
<Show
when={account()}
fallback={
<A
href="/login"
class="text-sm py-1.5 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Log In
</A>
}
>
Log In
</A>
<A href="/account">
<Show
when={picture()}
fallback={
<div class="h-8 w-8 rounded-full bg-gray-200 flex items-center justify-center">
<svg class="w-4 h-4 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
}
>
<img
src={picture()}
alt="Profile"
class="h-8 w-8 rounded-full object-cover"
/>
</Show>
</A>
</Show>
</div>
</div>
</nav>
+41
View File
@@ -1,4 +1,6 @@
import { createEffect, createSignal, onCleanup } from "solid-js"
import { EventStore } from "applesauce-core"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import { RelayPool } from "applesauce-relay"
import { AccountManager } from "applesauce-accounts"
import type { IAccount, SerializedAccount } from "applesauce-accounts"
@@ -8,6 +10,8 @@ import { NostrConnectSigner } from "applesauce-signers"
export const API_URL = import.meta.env.VITE_API_URL
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME || "Caravel"
const PROFILE_RELAYS = ["wss://purplepag.es", "wss://relay.damus.io", "wss://nos.lol"]
export const eventStore = new EventStore()
export const pool = new RelayPool()
export const accounts = new AccountManager()
@@ -51,3 +55,40 @@ export function persistAccounts() {
localStorage.removeItem(ACTIVE_ACCOUNT_STORAGE_KEY)
}
}
export function useActiveAccount() {
const [account, setAccount] = createSignal(accounts.active)
const sub = accounts.active$.subscribe(setAccount)
onCleanup(() => sub.unsubscribe())
return account
}
export function useProfilePicture(pubkey: () => string | undefined) {
const [picture, setPicture] = createSignal<string | undefined>()
createEffect(() => {
const pk = pubkey()
if (!pk) {
setPicture(undefined)
return
}
// Subscribe to profile changes in the event store
const profileSub = eventStore.profile(pk).subscribe(profile => {
setPicture(getProfilePicture(profile))
})
// Fetch the kind 0 from relays and add to the event store
const reqSub = pool.request(PROFILE_RELAYS, { kinds: [0], authors: [pk] }).subscribe(event => {
eventStore.add(event)
})
onCleanup(() => {
profileSub.unsubscribe()
reqSub.unsubscribe()
})
})
return picture
}
+2 -2
View File
@@ -4,7 +4,7 @@ export default function Home() {
return (
<div class="min-h-screen bg-white">
{/* Hero */}
<div class="max-w-5xl mx-auto px-4 py-24 text-center">
<div class="max-w-4xl mx-auto px-4 py-24 text-center">
<h1 class="text-5xl font-bold text-gray-900 mb-4">
Host Your Own Nostr Community Relay
</h1>
@@ -21,7 +21,7 @@ export default function Home() {
</div>
{/* Pricing */}
<div class="max-w-5xl mx-auto px-4 pb-24">
<div class="max-w-4xl mx-auto px-4 pb-24">
<h2 class="text-3xl font-bold text-center text-gray-900 mb-12">Pricing</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="border border-gray-200 rounded-xl p-6">
+235 -125
View File
@@ -1,14 +1,18 @@
import { Show, createSignal, onCleanup, onMount } from "solid-js"
import { Show, createSignal, onCleanup } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { ExtensionAccount, NostrConnectAccount, PasswordAccount, PrivateKeyAccount } from "applesauce-accounts/accounts"
import { PasswordSigner } from "applesauce-signers"
import QrScanner from "qr-scanner"
import QRCode from "qrcode"
import { activateAccount } from "../lib/nostr"
import { PLATFORM_NAME } from "../lib/nostr"
const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc']
type Tab = "nip07" | "nip46" | "key"
type Screen = "tabs" | "nip46" | "key"
type SignerTab = "qr" | "paste"
type KeyTab = "plaintext" | "encrypted"
function normalizeBunkerUrl(value: string): string {
const trimmed = value.trim()
@@ -37,8 +41,12 @@ export default function Login() {
const [tab, setTab] = createSignal<Tab>(window.nostr ? "nip07" : "nip46")
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal("")
const [screen, setScreen] = createSignal<Screen>("tabs")
const [signerTab, setSignerTab] = createSignal<SignerTab>("qr")
const [keyTab, setKeyTab] = createSignal<KeyTab>("plaintext")
const [nostrConnectUri, setNostrConnectUri] = createSignal("")
const [qrDataUrl, setQrDataUrl] = createSignal("")
const [showScanner, setShowScanner] = createSignal(false)
const [bunkerUrl, setBunkerUrl] = createSignal("")
const [nsecValue, setNsecValue] = createSignal("")
@@ -79,6 +87,7 @@ export default function Login() {
url: window.location.origin,
})
setNostrConnectUri(uri)
setQrDataUrl(await QRCode.toDataURL(uri, { width: 256, margin: 2 }))
abortController = new AbortController()
const timeout = window.setTimeout(() => abortController?.abort(), 60_000)
@@ -141,24 +150,25 @@ export default function Login() {
}
}
async function toggleScanner() {
const next = !showScanner()
setShowScanner(next)
if (!next) {
scanner?.destroy()
scanner = undefined
return
}
function closeScanner() {
setShowScanner(false)
scanner?.destroy()
scanner = undefined
}
async function openScanner() {
setError("")
setShowScanner(true)
// Wait a tick for the modal video element to mount
await new Promise(r => setTimeout(r, 0))
if (!scannerVideo) return
scanner = new QrScanner(
scannerVideo,
(result) => {
setBunkerUrl(result.data)
setShowScanner(false)
scanner?.destroy()
scanner = undefined
closeScanner()
},
{ returnDetailedScanResult: true },
)
@@ -166,12 +176,21 @@ export default function Login() {
await scanner.start()
} catch (e) {
setError(e instanceof Error ? e.message : "Unable to access camera")
setShowScanner(false)
scanner?.destroy()
scanner = undefined
closeScanner()
}
}
function enterSignerScreen() {
setError("")
setScreen("nip46")
setSignerTab("qr")
if (!nostrConnectUri()) void startNostrConnect()
}
function copyUri() {
void navigator.clipboard.writeText(nostrConnectUri())
}
onCleanup(() => {
abortController?.abort()
scanner?.destroy()
@@ -204,111 +223,187 @@ export default function Login() {
</div>
<div class="flex items-start gap-3">
<span class="mt-1 h-2 w-2 rounded-full bg-indigo-500" />
<p>Fast access to your relays and invoices.</p>
<p>Get fast access to your relays.</p>
</div>
</div>
</div>
<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">
Use any Nostr signer method. New users are automatically onboarded.
</p>
<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={screen() === "tabs"}>
<h2 class="text-lg font-semibold text-gray-900">Log in / Sign up</h2>
<p class="mt-2 text-xs text-gray-500">
Use any Nostr signer method. New users are automatically onboarded.
</p>
<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}
>
Extension
</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")}
>
Signer
</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>
<Show when={tab() === "nip07"}>
<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"}
{loading() ? "Connecting..." : <>Continue with extension <svg class="inline-block w-4 h-4 ml-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg></>}
</button>
</Show>
<Show when={tab() === "nip46"}>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium"
onClick={enterSignerScreen}
>
Continue with signer <svg class="inline-block w-4 h-4 ml-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</Show>
<Show when={tab() === "key"}>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium"
onClick={() => { setError(""); setScreen("key") }}
>
Continue with key <svg class="inline-block w-4 h-4 ml-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</Show>
</div>
</Show>
<Show when={screen() === "nip46"}>
<button
type="button"
class="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900"
onClick={() => { setError(""); setScreen("tabs") }}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Back
</button>
<h2 class="mt-3 text-lg font-semibold text-gray-900">Log in with signer</h2>
<div class="mt-4 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 ${signerTab() === "qr" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => setSignerTab("qr")}
>
Use QR Code
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${signerTab() === "paste" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => setSignerTab("paste")}
>
Paste Link
</button>
</div>
</Show>
<Show when={tab() === "nip46"}>
<div class="space-y-3">
<Show when={signerTab() === "qr"}>
<Show when={qrDataUrl()} fallback={
<div class="flex items-center justify-center py-8 text-sm text-gray-400">
{loading() ? "Generating..." : "Loading QR code..."}
</div>
}>
<img src={qrDataUrl()} alt="Nostrconnect QR code" class="mx-auto rounded-lg" />
</Show>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={nostrConnectUri()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={copyUri}
title="Copy link"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
</div>
</Show>
<Show when={signerTab() === "paste"}>
<div class="flex rounded-lg border border-gray-300">
<input
value={bunkerUrl()}
onInput={(e) => setBunkerUrl(e.currentTarget.value)}
placeholder="bunker://..."
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-sm bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={openScanner}
title="Scan QR code"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
</button>
</div>
<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}
disabled={loading() || !bunkerUrl().trim()}
onClick={loginWithBunker}
>
Generate nostrconnect QR
Connect to Signer
</button>
</Show>
</div>
</Show>
<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>
<Show when={screen() === "key"}>
<button
type="button"
class="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900"
onClick={() => { setError(""); setScreen("tabs") }}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Back
</button>
<h2 class="mt-3 text-lg font-semibold text-gray-900">Log in with key</h2>
<div class="mt-4 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 ${keyTab() === "plaintext" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => setKeyTab("plaintext")}
>
Plaintext
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${keyTab() === "encrypted" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => setKeyTab("encrypted")}
>
Encrypted
</button>
</div>
</Show>
<Show when={tab() === "key"}>
<form
class="space-y-3"
onSubmit={(e) => {
@@ -316,42 +411,43 @@ export default function Login() {
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"
/>
<Show when={keyTab() === "plaintext"}>
<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"
/>
</Show>
<Show when={keyTab() === "encrypted"}>
<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"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</Show>
<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
Log in
</button>
</form>
</Show>
</div>
</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>
<Show when={error()}>
<p class="mt-4 text-xs text-red-600">{error()}</p>
</Show>
</div>
</div>
</div>
@@ -359,6 +455,20 @@ export default function Login() {
Having trouble? Make sure your signer is unlocked and connected.
</p>
</div>
<Show when={showScanner()}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={closeScanner}>
<div class="relative w-full max-w-sm rounded-2xl bg-white p-4 shadow-xl" onClick={(e) => e.stopPropagation()}>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900">Scan QR Code</h3>
<button type="button" class="text-gray-400 hover:text-gray-700" onClick={closeScanner}>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<video ref={scannerVideo} class="w-full rounded-lg" />
</div>
</div>
</Show>
</div>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
export default function AdminRelays() {
return (
<div class="max-w-5xl mx-auto px-4 py-8">
<div class="max-w-4xl mx-auto px-4 py-8">
<h1 class="text-2xl font-bold text-gray-900 mb-6">All Relays</h1>
<input
type="search"
+1 -1
View File
@@ -1,6 +1,6 @@
export default function AdminTenants() {
return (
<div class="max-w-5xl mx-auto px-4 py-8">
<div class="max-w-4xl mx-auto px-4 py-8">
<h1 class="text-2xl font-bold text-gray-900 mb-6">Tenants</h1>
<input
type="search"