// 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, KeyRound } 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; } const NOSTRCONNECT_RELAYS = [ 'wss://relay.nsec.app', 'wss://relay.damus.io', 'wss://nos.lol', ]; // Key for storing pending nostrconnect session across app restarts const PENDING_NC_KEY = 'skatespots_pending_nc'; interface PendingNostrConnect { clientNsec: string; clientPubkey: string; secret: string; relays: string[]; timestamp: number; } 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()}`; } function randomSecret(): string { const arr = new Uint8Array(12); crypto.getRandomValues(arr); return Array.from(arr, b => b.toString(16).padStart(2, '0')).join(''); } function savePendingNC(data: PendingNostrConnect) { try { localStorage.setItem(PENDING_NC_KEY, JSON.stringify(data)); } catch { /* ignore */ } } function loadPendingNC(): PendingNostrConnect | null { try { const s = localStorage.getItem(PENDING_NC_KEY); if (!s) return null; const d = JSON.parse(s) as PendingNostrConnect; // Expire after 10 minutes if (Date.now() - d.timestamp > 10 * 60 * 1000) { localStorage.removeItem(PENDING_NC_KEY); return null; } return d; } catch { return null; } } function clearPendingNC() { try { localStorage.removeItem(PENDING_NC_KEY); } catch { /* ignore */ } } export const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onSignup }) => { const [isLoading, setIsLoading] = useState(false); const [bunkerUri, setBunkerUri] = useState(''); const [bunkerError, setBunkerError] = useState(''); const [nsecInput, setNsecInput] = useState(''); const [nsecError, setNsecError] = useState(''); const [amberStep, setAmberStep] = useState<'idle' | 'connecting' | 'waiting' | 'error' | '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 pendingRef = useRef(null); const amberStepRef = useRef<'idle' | 'connecting' | 'waiting' | 'error' | 'success'>('idle'); const login = useLoginActions(); const { addLogin, setLogin } = useNostrLogin(); const { toast } = useToast(); const isAndroid = /Android/i.test(navigator.userAgent); const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.(); const cleanup = useCallback(() => { try { subRef.current?.close(); } catch { /* ignore */ } try { poolRef.current?.close(NOSTRCONNECT_RELAYS); } catch { /* ignore */ } subRef.current = null; poolRef.current = null; }, []); // Complete login once we have signerPubkey from any source const completeLogin = useCallback(async (signerPubkey: string, clientSk: Uint8Array, clientNsec: string, relays: string[]) => { setAmberStatus('Getting your pubkey...'); let userPubkey = signerPubkey; // Try to get the real user pubkey via NConnectSigner try { const clientSigner = new NSecSigner(clientSk); const relay = new NRelay1(relays[0]); const signer = new NConnectSigner({ pubkey: signerPubkey, signer: clientSigner, relay, timeout: 15000, }); userPubkey = await signer.getPublicKey(); try { relay.close(); } catch { /* ignore */ } } catch { // Amber signs as the user's key directly, so signerPubkey IS the userPubkey userPubkey = signerPubkey; } const nlogin = new NLogin('bunker', userPubkey, { bunkerPubkey: signerPubkey, clientNsec, relays, }); addLogin(nlogin); setLogin(nlogin.id); clearPendingNC(); cleanup(); amberStepRef.current = 'success'; setAmberStep('success'); toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' }); setTimeout(() => { onLogin(); onClose(); }, 800); }, [addLogin, setLogin, cleanup, onLogin, onClose, toast]); // Listen for app visibility change (user returns from Amber) — then poll relay // This is more reliable in Capacitor than keeping a WebSocket open in background useEffect(() => { const handleVisibility = async () => { if (document.visibilityState !== 'visible') return; const pending = pendingRef.current || loadPendingNC(); if (!pending || amberStep !== 'waiting') return; setAmberStatus('Checking for Amber response...'); try { const decoded = nip19.decode(pending.clientNsec); if (decoded.type !== 'nsec') return; const clientSk = decoded.data; const since = Math.floor(pending.timestamp / 1000) - 30; // Re-subscribe with a fresh pool each time app comes to foreground const freshPool = new SimplePool(); let found = false; await new Promise((resolve) => { const timeout = setTimeout(() => { resolve(); }, 6000); const sub = freshPool.subscribeMany( NOSTRCONNECT_RELAYS, [{ kinds: [24133], '#p': [pending.clientPubkey], since }], { onevent: async (event) => { if (found) return; try { 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 !== '') return; if (parsed.result !== pending.secret && parsed.result !== 'ack') return; found = true; clearTimeout(timeout); sub.close(); freshPool.close(NOSTRCONNECT_RELAYS); pendingRef.current = null; abortRef.current?.abort(); await completeLogin(event.pubkey, clientSk, pending.clientNsec, pending.relays); resolve(); } catch { /* wrong encryption */ } }, oneose: () => { // All caught up — wait a bit more then give up setTimeout(() => { sub.close(); freshPool.close(NOSTRCONNECT_RELAYS); resolve(); }, 2000); }, } ); }); if (!found) { setAmberStatus('Not received yet. Did you approve in Amber? Tap retry or come back after approving.'); } } catch { /* ignore */ } }; document.addEventListener('visibilitychange', handleVisibility); return () => document.removeEventListener('visibilitychange', handleVisibility); }, [amberStep, completeLogin]); // Reset when dialog closes useEffect(() => { if (!isOpen) { abortRef.current?.abort(); abortRef.current = null; cleanup(); amberStepRef.current = 'idle'; setAmberStep('idle'); setAmberStatus(''); setNostrConnectUri(''); setBunkerUri(''); setBunkerError(''); setNsecInput(''); setNsecError(''); setIsLoading(false); } }, [isOpen, cleanup]); const startAmberConnect = useCallback(async () => { abortRef.current?.abort(); cleanup(); const controller = new AbortController(); abortRef.current = controller; const clientSk = generateSecretKey(); const clientPubkey = getPublicKey(clientSk); const clientNsec = nip19.nsecEncode(clientSk); const secret = randomSecret(); 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', }); const pending: PendingNostrConnect = { clientNsec, clientPubkey, secret, relays: NOSTRCONNECT_RELAYS, timestamp: Date.now(), }; pendingRef.current = pending; savePendingNC(pending); setNostrConnectUri(uri); amberStepRef.current = 'connecting'; setAmberStep('connecting'); setAmberStatus('Opening Amber...'); console.log('[Amber] Generated nostrconnect URI:', uri); console.log('[Amber] Relays:', NOSTRCONNECT_RELAYS); console.log('[Amber] Client pubkey:', clientPubkey); try { window.open(uri, '_blank'); } catch { /* user copies manually */ } amberStepRef.current = 'waiting'; setAmberStep('waiting'); setAmberStatus('Waiting for Amber to approve...'); // Live relay subscription (works on web/PWA; may be backgrounded in Capacitor) const pool = new SimplePool(); poolRef.current = pool; const since = Math.floor(Date.now() / 1000) - 10; console.log('[Amber] Subscribing to relays since:', since); const sub = pool.subscribeMany( NOSTRCONNECT_RELAYS, [{ kinds: [24133], '#p': [clientPubkey], since }], { onevent: async (event) => { if (controller.signal.aborted) return; console.log('[Amber] Received event from:', event.pubkey); try { const decrypted = await nip04.decrypt(clientSk, event.pubkey, event.content); const parsed = JSON.parse(decrypted) as { id: string; result: string; error?: string }; console.log('[Amber] Decrypted response:', parsed); if (parsed.error && parsed.error !== '') { console.warn('[Amber] Error in response:', parsed.error); return; } if (parsed.result !== secret && parsed.result !== 'ack') { console.warn('[Amber] Secret mismatch, ignoring'); return; } console.log('[Amber] Valid response — completing login'); controller.abort(); pendingRef.current = null; await completeLogin(event.pubkey, clientSk, clientNsec, NOSTRCONNECT_RELAYS); } catch (e) { console.warn('[Amber] Failed to decrypt event:', e); } }, } ); subRef.current = sub; // Timeout after 20 seconds — show error, let user retry setTimeout(() => { if (!controller.signal.aborted) { console.warn('[Amber] Timed out after 20s — no response received'); controller.abort(); cleanup(); if (amberStepRef.current === 'waiting') { amberStepRef.current = 'error'; setAmberStep('error'); setAmberStatus('Amber did not respond in time. Did you approve the connection?'); } } }, 20 * 1000); }, [cleanup, completeLogin, 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(`Failed: ${(e as Error)?.message ?? 'Check your bunker URI'}`); } finally { setIsLoading(false); } }; const handleNsecLogin = () => { const val = nsecInput.trim(); if (!val.startsWith('nsec1')) { setNsecError('Must start with nsec1'); return; } try { login.nsec(val); toast({ title: '🛹 Logged in!' }); onLogin(); onClose(); } catch (e: unknown) { setNsecError(`Invalid nsec: ${(e as Error)?.message ?? 'Check your key'}`); } }; const handleExtensionLogin = async () => { try { await login.extension(); onLogin(); onClose(); } catch { toast({ title: 'No extension found', description: 'Install Alby or another NIP-07 extension.', variant: 'destructive' }); } }; const copyUri = () => { navigator.clipboard.writeText(nostrConnectUri).catch(() => {}); toast({ title: 'Copied!', description: 'Open Amber → Scan / Paste link → paste it' }); }; 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.

Or log in
Amber Bunker nsec Ext {/* ── AMBER TAB ── */} {(amberStep === 'idle' || amberStep === 'connecting') && (

Login with Amber

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

)} {(amberStep === 'waiting' || amberStep === 'error') && (
{amberStep === 'error' ?

⏱ Timed out

: }

{amberStep === 'error' ? 'No response from Amber' : 'Waiting for Amber...'}

{amberStatus}

In Amber:

1. Tap Scan / Paste link

2. Paste the connection string below

3. Tap Approve

4. Come back here

{isCapacitor && (

After approving in Amber, come back to this app. It will detect the connection automatically.

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

Logged in!

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

Bunker (NIP-46)

Paste your 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}

}
{/* ── NSEC TAB ── */}

Secret Key (nsec)

Paste your Nostr secret key directly.

⚠️ Only use this on a trusted device. Never share your nsec.

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

{nsecError}

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

Browser Extension (NIP-07)

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

); }; export default LoginDialog;