import { Show, createSignal, createEffect, onCleanup } from "solid-js" import { useNavigate, type RouteSectionProps } 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 { accountManager, ensureSessionTenant, identity, PLATFORM_NAME } from "@/lib/state" import { copyToClipboard } from "@/lib/clipboard" import { validateBunkerUri } from "@/lib/validation" import { decideKeyLogin, initialLoginTab, normalizeBunkerUrl, type Tab } from "@/lib/loginInput" import useMinLoading from "@/lib/useMinLoading" import LoginTabsScreen from "@/components/login/LoginTabsScreen" import LoginSignerScreen from "@/components/login/LoginSignerScreen" import LoginKeyScreen from "@/components/login/LoginKeyScreen" import QrScannerOverlay from "@/components/login/QrScannerOverlay" const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc'] type Screen = "tabs" | "nip46" | "key" type SignerTab = "qr" | "paste" type KeyTab = "plaintext" | "encrypted" type LoginProps = { inModal?: boolean onClose?: () => void onAuthenticated?: () => void | Promise } async function loadNostrConnectSigner() { return import("applesauce-signers").then((m) => m.NostrConnectSigner) } type LoginPageProps = LoginProps & Partial> export default function Login(props: LoginPageProps = {}) { const navigate = useNavigate() const [tab, setTab] = createSignal(initialLoginTab(Boolean(window.nostr))) const [rawLoading, setRawLoading] = createSignal(false) const loading = useMinLoading(() => rawLoading()) const [error, setError] = createSignal("") const [screen, setScreen] = createSignal("tabs") const [signerTab, setSignerTab] = createSignal("qr") const [keyTab, setKeyTab] = createSignal("plaintext") const [nostrConnectUri, setNostrConnectUri] = createSignal("") const [qrDataUrl, setQrDataUrl] = createSignal("") const [showScanner, setShowScanner] = createSignal(false) const [bunkerUrl, setBunkerUrl] = createSignal("") const [nsecValue, setNsecValue] = createSignal("") const [ncryptsecValue, setNcryptsecValue] = createSignal("") const [password, setPassword] = createSignal("") let scannerVideo: HTMLVideoElement | undefined let scanner: QrScanner | undefined let abortController: AbortController | undefined async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) { accountManager.addAccount(account) accountManager.setActive(account) try { await ensureSessionTenant() } catch (e) { accountManager.removeAccount(account) throw e } await props.onAuthenticated?.() } // Shared effect wrapper for the four login handlers: clear the error, hold the // loading flag for the duration, and surface any thrown error with a per-handler // fallback message. The signer construction / completeLogin lives in `fn`. async function runLogin(fn: () => Promise, fallbackMessage: string) { setError("") setRawLoading(true) try { await fn() } catch (e) { setError(e instanceof Error ? e.message : fallbackMessage) } finally { setRawLoading(false) } } function loginWithNip07() { return runLogin(async () => { await completeLogin(await ExtensionAccount.fromExtension()) }, "Failed to login with extension") } function startNostrConnect() { return runLogin(async () => { const NostrConnectSigner = await loadNostrConnectSigner() const signer = new NostrConnectSigner({ relays: NIP46_RELAYS }) const uri = signer.getNostrConnectURI({ name: PLATFORM_NAME, 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) try { await signer.waitForSigner(abortController.signal) const pubkey = await signer.getPublicKey() const account = new NostrConnectAccount(pubkey, signer) await completeLogin(account) } finally { window.clearTimeout(timeout) } }, "Failed to connect signer") } function loginWithBunker() { setError("") const validationError = validateBunkerUri(bunkerUrl()) if (validationError) { setError(validationError) return } return runLogin(async () => { 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) }, "Invalid bunker URL") } function loginWithKeyMaterial() { return runLogin(async () => { const plan = decideKeyLogin({ ncryptsec: ncryptsecValue(), nsec: nsecValue(), password: password() }) switch (plan.kind) { case "error": throw new Error(plan.message) case "ncryptsec": { const signer = await PasswordSigner.fromNcryptsec(plan.ncryptsec, plan.password) const pubkey = await signer.getPublicKey() await completeLogin(new PasswordAccount(pubkey, signer)) break } case "nsec": await completeLogin(PrivateKeyAccount.fromKey(plan.key)) break } }, "Invalid key") } 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) closeScanner() }, { returnDetailedScanResult: true }, ) try { await scanner.start() } catch (e) { setError(e instanceof Error ? e.message : "Unable to access camera") closeScanner() } } function enterSignerScreen() { setError("") setScreen("nip46") setSignerTab("qr") if (!nostrConnectUri()) void startNostrConnect() } function copyUri() { void copyToClipboard(nostrConnectUri(), { successMessage: "Link copied" }) } createEffect(() => { if (!props.inModal && identity()) { navigate("/relays") } }) onCleanup(() => { abortController?.abort() scanner?.destroy() }) return (
Secure Nostr Login

Welcome!

Connect your Nostr account to manage relay hosting, billing, and access in one place.

Own your identity with cryptographic sign-in.

No passwords or email required.

Get fast access to your relays.

{ setError(""); setScreen("key") }} /> { setError(""); setScreen("tabs") }} onCopyUri={copyUri} onScan={openScanner} onConnectBunker={loginWithBunker} /> { setError(""); setScreen("tabs") }} onSubmit={() => void loginWithKeyMaterial()} />

{error()}

Having trouble? Make sure your signer is unlocked and connected.

{ scannerVideo = el }} />
) }