From 0fa79cee58befb45b531f4dc9d7bc688152d9c1d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 10:02:48 +0000 Subject: [PATCH] feat: add NIP-46 remote signer login support Add a login dialog with two authentication options: - Extension login (NIP-07): Connect via browser extensions like nos2x, Alby - Nostr Connect (NIP-46): Login via QR code scan or bunker:// URL The dialog allows users to generate a nostrconnect:// QR code that can be scanned with a signer app, or paste a bunker:// URL for direct connection. --- src/components/nostr/LoginDialog.tsx | 442 +++++++++++++++++++++++++++ src/components/nostr/user-menu.tsx | 23 +- 2 files changed, 449 insertions(+), 16 deletions(-) create mode 100644 src/components/nostr/LoginDialog.tsx diff --git a/src/components/nostr/LoginDialog.tsx b/src/components/nostr/LoginDialog.tsx new file mode 100644 index 0000000..ac9845c --- /dev/null +++ b/src/components/nostr/LoginDialog.tsx @@ -0,0 +1,442 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { + ExtensionSigner, + NostrConnectSigner, + PrivateKeySigner, +} from "applesauce-signers"; +import { + ExtensionAccount, + NostrConnectAccount, +} from "applesauce-accounts/accounts"; +import { generateSecretKey } from "nostr-tools"; +import QRCode from "qrcode"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Loader2, + Puzzle, + QrCode, + Copy, + Check, + AlertCircle, +} from "lucide-react"; +import accounts from "@/services/accounts"; +import pool from "@/services/relay-pool"; + +// Default relays for NIP-46 communication +const DEFAULT_NIP46_RELAYS = [ + "wss://relay.nsec.app", + "wss://relay.damus.io", + "wss://nos.lol", +]; + +type LoginTab = "extension" | "nostr-connect"; + +interface LoginDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { + const [tab, setTab] = useState("extension"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // NIP-46 state + const [bunkerUrl, setBunkerUrl] = useState(""); + const [qrDataUrl, setQrDataUrl] = useState(null); + const [connectUri, setConnectUri] = useState(null); + const [waitingForSigner, setWaitingForSigner] = useState(false); + const [copied, setCopied] = useState(false); + const abortControllerRef = useRef(null); + const signerRef = useRef(null); + + // Cleanup on unmount or dialog close + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + signerRef.current?.close(); + }; + }, []); + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + setLoading(false); + setError(null); + setBunkerUrl(""); + setQrDataUrl(null); + setConnectUri(null); + setWaitingForSigner(false); + setCopied(false); + abortControllerRef.current?.abort(); + signerRef.current?.close(); + signerRef.current = null; + } + }, [open]); + + const handleSuccess = useCallback( + (account: ExtensionAccount | NostrConnectAccount) => { + accounts.addAccount(account); + accounts.setActive(account); + onOpenChange(false); + }, + [onOpenChange], + ); + + // Extension login + async function loginWithExtension() { + setLoading(true); + setError(null); + + try { + const signer = new ExtensionSigner(); + const pubkey = await signer.getPublicKey(); + const account = new ExtensionAccount(pubkey, signer); + handleSuccess(account); + } catch (err) { + console.error("Extension login error:", err); + setError( + err instanceof Error ? err.message : "Failed to connect to extension", + ); + } finally { + setLoading(false); + } + } + + // Bunker URL login + async function loginWithBunkerUrl() { + if (!bunkerUrl.trim()) { + setError("Please enter a bunker URL"); + return; + } + + if (!bunkerUrl.startsWith("bunker://")) { + setError("Invalid bunker URL. Must start with bunker://"); + return; + } + + setLoading(true); + setError(null); + + try { + // Set up pool methods for the signer + NostrConnectSigner.pool = pool; + + const signer = await NostrConnectSigner.fromBunkerURI(bunkerUrl); + signerRef.current = signer; + + await signer.open(); + const pubkey = await signer.getPublicKey(); + + const account = new NostrConnectAccount(pubkey, signer); + handleSuccess(account); + } catch (err) { + console.error("Bunker login error:", err); + signerRef.current?.close(); + signerRef.current = null; + setError( + err instanceof Error ? err.message : "Failed to connect to bunker", + ); + } finally { + setLoading(false); + } + } + + // Generate QR code for remote signer connection + async function generateQrCode() { + setLoading(true); + setError(null); + setQrDataUrl(null); + setConnectUri(null); + setWaitingForSigner(true); + + try { + // Generate a new client key + const secretKey = generateSecretKey(); + const clientSigner = new PrivateKeySigner(secretKey); + + // Set up pool methods for the signer + NostrConnectSigner.pool = pool; + + // Create a new NostrConnectSigner + const signer = new NostrConnectSigner({ + relays: DEFAULT_NIP46_RELAYS, + signer: clientSigner, + }); + signerRef.current = signer; + + // Generate the nostrconnect:// URI + const uri = signer.getNostrConnectURI({ + name: "Grimoire", + url: window.location.origin, + }); + setConnectUri(uri); + + // Generate QR code + const dataUrl = await QRCode.toDataURL(uri, { + width: 256, + margin: 2, + color: { + dark: "#000000", + light: "#ffffff", + }, + }); + setQrDataUrl(dataUrl); + + // Open the signer to start listening + await signer.open(); + + // Set up abort controller for cancellation + abortControllerRef.current = new AbortController(); + + setLoading(false); + + // Wait for the remote signer to connect + await signer.waitForSigner(abortControllerRef.current.signal); + + // Get the user's pubkey + const pubkey = await signer.getPublicKey(); + + const account = new NostrConnectAccount(pubkey, signer); + handleSuccess(account); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + // User cancelled, don't show error + return; + } + console.error("QR login error:", err); + signerRef.current?.close(); + signerRef.current = null; + setError(err instanceof Error ? err.message : "Failed to connect"); + } finally { + setLoading(false); + setWaitingForSigner(false); + } + } + + // Copy connect URI to clipboard + async function copyConnectUri() { + if (!connectUri) return; + + try { + await navigator.clipboard.writeText(connectUri); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy:", err); + } + } + + // Cancel QR code waiting + function cancelQrLogin() { + abortControllerRef.current?.abort(); + signerRef.current?.close(); + signerRef.current = null; + setQrDataUrl(null); + setConnectUri(null); + setWaitingForSigner(false); + } + + const hasExtension = typeof window !== "undefined" && "nostr" in window; + + return ( + + + + Log in to Grimoire + + Choose a login method to access your Nostr identity + + + + setTab(v as LoginTab)}> + + + + Extension + + + + Nostr Connect + + + + +

+ Log in using a browser extension like nos2x, Alby, or similar + NIP-07 compatible extensions. +

+ + {!hasExtension && ( +
+ + + No extension detected. Please install a Nostr extension to use + this login method. + +
+ )} + + {error && tab === "extension" && ( +
+ + {error} +
+ )} + + +
+ + +

+ Log in using NIP-46 remote signing. Scan the QR code with a signer + app or paste a bunker URL. +

+ + {error && tab === "nostr-connect" && ( +
+ + {error} +
+ )} + + {/* QR Code Section */} +
+ {qrDataUrl ? ( +
+
+ Nostr Connect QR Code +
+

+ {waitingForSigner + ? "Scan with your signer app and approve the connection" + : "Waiting for connection..."} +

+ {waitingForSigner && ( +
+ + + Waiting for approval... + +
+ )} +
+ + +
+
+ ) : ( + + )} +
+ + {/* Bunker URL Section */} +
+
+ +
+
+ + Or enter bunker URL + +
+
+ +
+ + setBunkerUrl(e.target.value)} + disabled={loading || waitingForSigner} + /> +
+ + +
+
+
+
+ ); +} diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index bd05dc4..f6dd4ed 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -1,7 +1,5 @@ import { User } from "lucide-react"; import accounts from "@/services/accounts"; -import { ExtensionSigner } from "applesauce-signers"; -import { ExtensionAccount } from "applesauce-accounts/accounts"; import { useProfile } from "@/hooks/useProfile"; import { use$ } from "applesauce-react/hooks"; import { getDisplayName } from "@/lib/nostr-utils"; @@ -20,6 +18,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import Nip05 from "./nip05"; import { RelayLink } from "./RelayLink"; import SettingsDialog from "@/components/SettingsDialog"; +import LoginDialog from "./LoginDialog"; import { useState } from "react"; function UserAvatar({ pubkey }: { pubkey: string }) { @@ -56,6 +55,7 @@ export default function UserMenu() { const { state, addWindow } = useGrimoire(); const relays = state.activeAccount?.relays; const [showSettings, setShowSettings] = useState(false); + const [showLogin, setShowLogin] = useState(false); function openProfile() { if (!account?.pubkey) return; @@ -66,18 +66,6 @@ export default function UserMenu() { ); } - async function login() { - try { - const signer = new ExtensionSigner(); - const pubkey = await signer.getPublicKey(); - const account = new ExtensionAccount(pubkey, signer); - accounts.addAccount(account); - accounts.setActive(account); - } catch (err) { - console.error(err); - } - } - async function logout() { if (!account) return; accounts.removeAccount(account); @@ -86,6 +74,7 @@ export default function UserMenu() { return ( <> + @@ -148,7 +137,9 @@ export default function UserMenu() { ) : ( - Log in + setShowLogin(true)}> + Log in + )}