diff --git a/package-lock.json b/package-lock.json index 67c4213..0f5972e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "media-chrome": "^4.17.2", "prismjs": "^1.30.0", "qrcode": "^1.5.4", + "qrcode.react": "^4.2.0", "react": "^19.2.1", "react-dom": "^19.2.1", "react-markdown": "^10.1.0", @@ -8682,6 +8683,15 @@ "node": ">=10.13.0" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index 3b8477a..9d54802 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "media-chrome": "^4.17.2", "prismjs": "^1.30.0", "qrcode": "^1.5.4", + "qrcode.react": "^4.2.0", "react": "^19.2.1", "react-dom": "^19.2.1", "react-markdown": "^10.1.0", diff --git a/src/components/LoginDialog.tsx b/src/components/LoginDialog.tsx index 1b51157..533e0b2 100644 --- a/src/components/LoginDialog.tsx +++ b/src/components/LoginDialog.tsx @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { toast } from "sonner"; -import { Eye, Puzzle, Link2 } from "lucide-react"; +import { Eye, Puzzle, Link2, QrCode, Keyboard } from "lucide-react"; +import { QRCodeSVG } from "qrcode.react"; import { Dialog, DialogContent, @@ -11,7 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import accountManager from "@/services/accounts"; -import { ExtensionSigner, NostrConnectSigner } from "applesauce-signers"; +import { ExtensionSigner, NostrConnectSigner, PrivateKeySigner } from "applesauce-signers"; import { ExtensionAccount, NostrConnectAccount } from "applesauce-accounts/accounts"; import { createAccountFromInput } from "@/lib/login-parser"; @@ -25,6 +26,92 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { const [bunkerInput, setBunkerInput] = useState(""); const [loading, setLoading] = useState(false); + // NIP-46 QR mode state + const [useQrMode, setUseQrMode] = useState(true); + const [nostrConnectUri, setNostrConnectUri] = useState(""); + const [remoteSigner, setRemoteSigner] = useState(null); + const [isWaitingForConnection, setIsWaitingForConnection] = useState(false); + + // Generate nostrconnect:// URI when dialog opens in QR mode + useEffect(() => { + if (open && useQrMode && !remoteSigner) { + const initQrMode = async () => { + try { + // Create a temporary client signer + const clientSigner = new PrivateKeySigner(); + + // Create NostrConnectSigner with default relays + const signer = new NostrConnectSigner({ + signer: clientSigner, + relays: [ + "wss://relay.nsec.app", + "wss://relay.damus.io", + "wss://nos.lol", + ], + }); + + await signer.open(); + + // Generate nostrconnect:// URI with app metadata + const uri = signer.getNostrConnectURI({ + name: "Grimoire", + url: window.location.origin, + }); + + setNostrConnectUri(uri); + setRemoteSigner(signer); + + // Start waiting for connection + setIsWaitingForConnection(true); + + // Wait for remote signer to connect (with 5 minute timeout) + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), 5 * 60 * 1000); + + try { + await signer.waitForSigner(abortController.signal); + clearTimeout(timeoutId); + + // Connection established, get pubkey and create account + const pubkey = await signer.getPublicKey(); + const account = new NostrConnectAccount(pubkey, signer); + accountManager.addAccount(account); + accountManager.setActive(account.id); + + toast.success("Connected to remote signer"); + onOpenChange(false); + + // Cleanup + setRemoteSigner(null); + setNostrConnectUri(""); + setIsWaitingForConnection(false); + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === "AbortError") { + toast.error("Connection timeout. Please try again."); + } + // Reset on error but keep dialog open + setIsWaitingForConnection(false); + } + } catch (error) { + console.error("Failed to initialize QR mode:", error); + toast.error("Failed to generate connection code"); + } + }; + + initQrMode(); + } + + // Cleanup when dialog closes + return () => { + if (remoteSigner && !isWaitingForConnection) { + remoteSigner.close(); + setRemoteSigner(null); + setNostrConnectUri(""); + } + }; + }, [open, useQrMode]); + const handleReadonlyLogin = async () => { if (!readonlyInput.trim()) { toast.error("Please enter an identifier"); @@ -175,33 +262,90 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { -
- - setBunkerInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleRemoteSignerLogin(); - }} - disabled={loading} - /> -

- Connect to a remote signer using NIP-46 (Nostr Connect). Paste - your bunker:// URI from your remote signer app. -

+ {/* Toggle between QR and manual input */} +
+ +
- + {useQrMode ? ( +
+
+ {nostrConnectUri ? ( + <> + + {isWaitingForConnection && ( +
+
+
+ Waiting for remote signer... +
+
+ )} + + ) : ( +
+
+ Generating connection code... +
+ )} +
+

+ Scan this QR code with your remote signer app (like Amber, + nsec.app, or any NIP-46 compatible app) +

+
+ ) : ( +
+
+ + setBunkerInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleRemoteSignerLogin(); + }} + disabled={loading} + /> +

+ Paste your bunker:// URI from your remote signer app +

+
+ + +
+ )}