// NOTE: This file is stable and usually should not be modified. // It is important that all functionality in this file is preserved, and should only be modified if explicitly requested. import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, Copy, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useLoginActions } from '@/hooks/useLoginActions'; import { useToast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; import { NLogin } from '@nostrify/react/login'; import { useNostrLogin } from '@nostrify/react/login'; import { NConnectSigner, NSecSigner, NRelay1 } from '@nostrify/nostrify'; import { SimplePool, generateSecretKey, getPublicKey, nip19, nip04 } from 'nostr-tools'; interface LoginDialogProps { isOpen: boolean; onClose: () => void; onLogin: () => void; onSignup?: () => void; } // Relays used for NIP-46 nostrconnect:// handshake // Use relays known to support ephemeral events (kind 24133) const NOSTRCONNECT_RELAYS = [ 'wss://relay.nsec.app', 'wss://relay.damus.io', 'wss://relay.primal.net', ]; /** Build a nostrconnect:// URI per NIP-46 spec */ function buildNostrConnectURI(opts: { clientPubkey: string; relays: string[]; secret: string; name?: string; url?: string; perms?: string; }): string { const params = new URLSearchParams(); for (const r of opts.relays) { params.append('relay', r); } params.set('secret', opts.secret); if (opts.name) params.set('name', opts.name); if (opts.url) params.set('url', opts.url); if (opts.perms) params.set('perms', opts.perms); return `nostrconnect://${opts.clientPubkey}?${params.toString()}`; } /** Generate a random short secret string */ function randomSecret(): string { const arr = new Uint8Array(12); crypto.getRandomValues(arr); return Array.from(arr, b => b.toString(16).padStart(2, '0')).join(''); } export const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onSignup }) => { const [isLoading, setIsLoading] = useState(false); const [bunkerUri, setBunkerUri] = useState(''); const [bunkerError, setBunkerError] = useState(''); // Amber nostrconnect state const [amberStep, setAmberStep] = useState<'idle' | 'connecting' | 'success'>('idle'); const [amberStatus, setAmberStatus] = useState(''); const [nostrConnectUri, setNostrConnectUri] = useState(''); const abortRef = useRef(null); const poolRef = useRef(null); const subRef = useRef<{ close: () => void } | null>(null); const login = useLoginActions(); const { addLogin, setLogin } = useNostrLogin(); const { toast } = useToast(); const isAndroid = /Android/i.test(navigator.userAgent); // Cleanup pool/subscriptions const cleanup = useCallback(() => { try { subRef.current?.close(); } catch { /* ignore */ } try { poolRef.current?.close(NOSTRCONNECT_RELAYS); } catch { /* ignore */ } subRef.current = null; poolRef.current = null; }, []); // Reset state when dialog closes useEffect(() => { if (!isOpen) { abortRef.current?.abort(); abortRef.current = null; cleanup(); setAmberStep('idle'); setAmberStatus(''); setNostrConnectUri(''); setBunkerUri(''); setBunkerError(''); setIsLoading(false); } }, [isOpen, cleanup]); /** * Amber login via NIP-46 nostrconnect:// flow. * * Uses nostr-tools SimplePool + nip04 for reliable WebSocket subscriptions. * This is the same approach used by Ditto, Coracle, Nostrudel. */ const startAmberConnect = useCallback(async () => { // Cancel previous attempt abortRef.current?.abort(); cleanup(); const controller = new AbortController(); abortRef.current = controller; // Generate ephemeral client keypair const clientSk = generateSecretKey(); const clientPubkey = getPublicKey(clientSk); const clientNsec = nip19.nsecEncode(clientSk); const secret = randomSecret(); // Build the nostrconnect URI const uri = buildNostrConnectURI({ clientPubkey, relays: NOSTRCONNECT_RELAYS, secret, name: 'SkateSpots', url: 'https://skate-spots.org', perms: 'get_public_key,sign_event,nip44_encrypt,nip44_decrypt', }); setNostrConnectUri(uri); setAmberStep('connecting'); setAmberStatus('Opening Amber...'); // Open Amber with the nostrconnect:// URI try { window.open(uri, '_blank'); } catch { // fallback: user copies manually } setAmberStatus('Waiting for Amber to approve...'); // Set up SimplePool subscription to listen for Amber's connect response const pool = new SimplePool(); poolRef.current = pool; const since = Math.floor(Date.now() / 1000) - 5; const connectPromise = new Promise<{ signerPubkey: string; userPubkey: string }>((resolve, reject) => { // Handle abort controller.signal.addEventListener('abort', () => { reject(new DOMException('Aborted', 'AbortError')); }); let resolved = false; // Subscribe using nostr-tools SimplePool — proven reliable const sub = pool.subscribeMany( NOSTRCONNECT_RELAYS, [{ kinds: [24133], '#p': [clientPubkey], since, }], { onevent: async (event) => { if (resolved) return; try { // Amber encrypts the connect response with NIP-04 const decrypted = await nip04.decrypt(clientSk, event.pubkey, event.content); const parsed = JSON.parse(decrypted) as { id: string; result: string; error?: string; }; if (parsed.error && parsed.error !== '') { resolved = true; reject(new Error(parsed.error)); return; } // Accept either our secret or 'ack' as valid connect response if (parsed.result !== secret && parsed.result !== 'ack') return; resolved = true; const signerPubkey = event.pubkey; setAmberStatus('Connected! Getting your pubkey...'); // Use NConnectSigner to get the actual user pubkey try { const clientSigner = new NSecSigner(clientSk); const relayForSigning = new NRelay1(NOSTRCONNECT_RELAYS[0]); const signer = new NConnectSigner({ pubkey: signerPubkey, signer: clientSigner, relay: relayForSigning, timeout: 30000, }); const userPubkey = await signer.getPublicKey(); try { relayForSigning.close(); } catch { /* ignore */ } resolve({ signerPubkey, userPubkey }); } catch { // If NConnectSigner fails, use signerPubkey as userPubkey // (works for Amber since it signs as the user's key directly) resolve({ signerPubkey, userPubkey: signerPubkey }); } } catch { // Decryption failed — not our event or wrong encryption, skip } }, oneose: () => { // EOSE received — we're caught up, still waiting for new events }, } ); subRef.current = sub; // Timeout after 5 minutes setTimeout(() => { if (!resolved) { reject(new Error('Timeout — Amber did not respond in 5 minutes')); } }, 5 * 60 * 1000); }); try { const { signerPubkey, userPubkey } = await connectPromise; cleanup(); // Create NLogin bunker object — same structure NLogin.fromBunker() creates const nlogin = new NLogin('bunker', userPubkey, { bunkerPubkey: signerPubkey, clientNsec, relays: NOSTRCONNECT_RELAYS, }); addLogin(nlogin); setLogin(nlogin.id); setAmberStep('success'); toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' }); setTimeout(() => { onLogin(); onClose(); }, 800); } catch (e: unknown) { cleanup(); if ((e as Error)?.name === 'AbortError') return; setAmberStep('idle'); setAmberStatus(''); const msg = (e as Error)?.message ?? 'Unknown error'; toast({ title: 'Connection failed', description: msg.includes('Timeout') ? 'Amber did not respond. Make sure you approved in Amber and try again.' : `Failed: ${msg}`, variant: 'destructive', }); } }, [addLogin, setLogin, cleanup, onLogin, onClose, toast]); const handleBunkerLogin = async () => { const uri = bunkerUri.trim(); if (!uri.startsWith('bunker://')) { setBunkerError('Must start with bunker://'); return; } setIsLoading(true); setBunkerError(''); try { await login.bunker(uri); toast({ title: '🛹 Logged in!', description: 'Connected via bunker!' }); onLogin(); onClose(); } catch (e: unknown) { setBunkerError( `Connection failed: ${(e as Error)?.message ?? 'Check your bunker URI and try again.'}` ); } finally { setIsLoading(false); } }; const handleExtensionLogin = async () => { try { await login.extension(); onLogin(); onClose(); } catch { toast({ title: 'No extension found', description: 'Install a Nostr browser extension like Alby.', variant: 'destructive' }); } }; const copyUri = () => { navigator.clipboard.writeText(nostrConnectUri).catch(() => {}); toast({ title: 'Copied!', description: 'Paste in Amber → Scan/Paste link' }); }; const openAmberWithUri = () => { if (nostrConnectUri) { try { window.open(nostrConnectUri, '_blank'); } catch { copyUri(); } } }; return ( Sign up or log in to continue
{/* Sign Up */}
New here?

Create a free account. No email required.

{/* Divider */}
Or log in
{/* Login tabs */} Amber Bunker Ext {/* ── AMBER TAB ── */} {amberStep === 'idle' && (

Login with Amber

NIP-46 remote signing. Tap below, approve in Amber, done.

Need Amber?{' '} Download →

)} {amberStep === 'connecting' && (

Waiting for Amber...

{amberStatus}

In Amber:

1. Open Amber

2. Tap Scan / Paste link

3. Paste the connection string

4. Tap Approve

)} {amberStep === 'success' && (

Logged in!

)}
{/* ── BUNKER TAB ── */}

Bunker Login (NIP-46)

Paste your bunker:// URI from Amber or nsec.app.

How to get a bunker URI:

Amber: Settings → NIP-46 Bunker → Create → Copy

nsec.app: Connect app → Copy bunker URI

{ setBunkerUri(e.target.value); setBunkerError(''); }} className='bg-black/60 border-blue-500/30 text-white placeholder:text-gray-600 text-xs font-mono' autoComplete='off' autoCorrect='off' spellCheck={false} /> {bunkerError &&

{bunkerError}

}
{/* ── EXTENSION TAB ── */}

Browser Extension (NIP-07)

Best for desktop. Works with Alby, nos2x, Flamingo.

); }; export default LoginDialog;