mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 23:47:12 +02:00
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.
This commit is contained in:
442
src/components/nostr/LoginDialog.tsx
Normal file
442
src/components/nostr/LoginDialog.tsx
Normal file
@@ -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<LoginTab>("extension");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// NIP-46 state
|
||||
const [bunkerUrl, setBunkerUrl] = useState("");
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [connectUri, setConnectUri] = useState<string | null>(null);
|
||||
const [waitingForSigner, setWaitingForSigner] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const signerRef = useRef<NostrConnectSigner | null>(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<unknown> | NostrConnectAccount<unknown>) => {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Log in to Grimoire</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a login method to access your Nostr identity
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as LoginTab)}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="extension" className="gap-2">
|
||||
<Puzzle className="size-4" />
|
||||
Extension
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="nostr-connect" className="gap-2">
|
||||
<QrCode className="size-4" />
|
||||
Nostr Connect
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="extension" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Log in using a browser extension like nos2x, Alby, or similar
|
||||
NIP-07 compatible extensions.
|
||||
</p>
|
||||
|
||||
{!hasExtension && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-yellow-500/50 bg-yellow-500/10 p-3 text-sm text-yellow-600 dark:text-yellow-400">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>
|
||||
No extension detected. Please install a Nostr extension to use
|
||||
this login method.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && tab === "extension" && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={loginWithExtension}
|
||||
disabled={loading || !hasExtension}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
"Connect with Extension"
|
||||
)}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="nostr-connect" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Log in using NIP-46 remote signing. Scan the QR code with a signer
|
||||
app or paste a bunker URL.
|
||||
</p>
|
||||
|
||||
{error && tab === "nostr-connect" && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR Code Section */}
|
||||
<div className="space-y-3">
|
||||
{qrDataUrl ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="rounded-lg bg-white p-2">
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="Nostr Connect QR Code"
|
||||
className="size-64"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{waitingForSigner
|
||||
? "Scan with your signer app and approve the connection"
|
||||
: "Waiting for connection..."}
|
||||
</p>
|
||||
{waitingForSigner && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Waiting for approval...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyConnectUri}
|
||||
disabled={!connectUri}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="mr-2 size-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="mr-2 size-4" />
|
||||
Copy URI
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={cancelQrLogin}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={generateQrCode}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<QrCode className="mr-2 size-4" />
|
||||
Generate QR Code
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bunker URL Section */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or enter bunker URL
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="bunker-url"
|
||||
className="text-sm font-medium leading-none"
|
||||
>
|
||||
Bunker URL
|
||||
</label>
|
||||
<Input
|
||||
id="bunker-url"
|
||||
placeholder="bunker://..."
|
||||
value={bunkerUrl}
|
||||
onChange={(e) => setBunkerUrl(e.target.value)}
|
||||
disabled={loading || waitingForSigner}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={loginWithBunkerUrl}
|
||||
disabled={loading || waitingForSigner || !bunkerUrl.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{loading && !waitingForSigner ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
"Connect with Bunker URL"
|
||||
)}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<SettingsDialog open={showSettings} onOpenChange={setShowSettings} />
|
||||
<LoginDialog open={showLogin} onOpenChange={setShowLogin} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -96,7 +85,7 @@ export default function UserMenu() {
|
||||
{account ? (
|
||||
<UserAvatar pubkey={account.pubkey} />
|
||||
) : (
|
||||
<User onClick={login} className="size-4 text-muted-foreground" />
|
||||
<User className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -148,7 +137,9 @@ export default function UserMenu() {
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={login}>Log in</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowLogin(true)}>
|
||||
Log in
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
Reference in New Issue
Block a user