diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index dcece04..9e6c051 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -2,7 +2,7 @@ // 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 { 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"; @@ -22,15 +22,23 @@ interface LoginDialogProps { 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 */ +// 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[]; @@ -40,9 +48,7 @@ function buildNostrConnectURI(opts: { perms?: string; }): string { const params = new URLSearchParams(); - for (const r of opts.relays) { - params.append('relay', r); - } + 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); @@ -50,33 +56,56 @@ function buildNostrConnectURI(opts: { 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(''); } +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(''); - // Amber nostrconnect state - const [amberStep, setAmberStep] = useState<'idle' | 'connecting' | 'success'>('idle'); + const [amberStep, setAmberStep] = useState<'idle' | 'connecting' | 'waiting' | '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 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?.(); - // Cleanup pool/subscriptions const cleanup = useCallback(() => { try { subRef.current?.close(); } catch { /* ignore */ } try { poolRef.current?.close(NOSTRCONNECT_RELAYS); } catch { /* ignore */ } @@ -84,7 +113,101 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog poolRef.current = null; }, []); - // Reset state when dialog closes + // 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(); + 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 relay for Amber response...'); + + // Quick one-shot relay check when app comes back to foreground + const { nsecDecode } = await import('nostr-tools/nip19').catch(() => ({ nsecDecode: null })); + if (!nsecDecode) return; + + try { + const decoded = nip19.decode(pending.clientNsec); + if (decoded.type !== 'nsec') return; + const clientSk = decoded.data; + + const pool = new SimplePool(); + const since = Math.floor(pending.timestamp / 1000) - 30; + + // Fetch recent events — one-shot query + const events = await pool.querySync( + NOSTRCONNECT_RELAYS, + { + kinds: [24133], + '#p': [pending.clientPubkey], + since, + }, + { maxWait: 5000 } + ); + + pool.close(NOSTRCONNECT_RELAYS); + + for (const event of events) { + 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 !== '') continue; + if (parsed.result !== pending.secret && parsed.result !== 'ack') continue; + + // Found it! + pendingRef.current = null; + abortRef.current?.abort(); + await completeLogin(event.pubkey, clientSk, pending.clientNsec, pending.relays); + return; + } catch { /* wrong encryption or format */ } + } + + setAmberStatus('No response yet — did you approve in Amber?'); + } catch { /* ignore */ } + }; + + document.addEventListener('visibilitychange', handleVisibility); + return () => document.removeEventListener('visibilitychange', handleVisibility); + }, [amberStep, completeLogin]); + + // Reset when dialog closes useEffect(() => { if (!isOpen) { abortRef.current?.abort(); @@ -95,31 +218,24 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog setNostrConnectUri(''); setBunkerUri(''); setBunkerError(''); + setNsecInput(''); + setNsecError(''); 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, @@ -129,181 +245,102 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog 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); setAmberStep('connecting'); setAmberStatus('Opening Amber...'); - // Open Amber with the nostrconnect:// URI - try { - window.open(uri, '_blank'); - } catch { - // fallback: user copies manually - } + try { window.open(uri, '_blank'); } catch { /* user copies manually */ } + setAmberStep('waiting'); setAmberStatus('Waiting for Amber to approve...'); - // Set up SimplePool subscription to listen for Amber's connect response + // Also keep a live relay subscription running (works on web/PWA, may sleep in Capacitor) 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')); - }); + const sub = pool.subscribeMany( + NOSTRCONNECT_RELAYS, + [{ kinds: [24133], '#p': [clientPubkey], since }], + { + onevent: async (event) => { + if (controller.signal.aborted) 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 !== secret && parsed.result !== 'ack') return; - let resolved = false; + controller.abort(); + pendingRef.current = null; + await completeLogin(event.pubkey, clientSk, clientNsec, NOSTRCONNECT_RELAYS); + } catch { /* ignore */ } + }, + } + ); + subRef.current = sub; - // 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 - }, + // Timeout after 10 minutes + setTimeout(() => { + if (!controller.signal.aborted) { + controller.abort(); + cleanup(); + if (amberStep === 'waiting') { + setAmberStep('idle'); + toast({ title: 'Timed out', description: 'Amber did not respond. Try again.', variant: 'destructive' }); } - ); - - 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]); + } + }, 10 * 60 * 1000); + }, [cleanup, completeLogin, amberStep, toast]); const handleBunkerLogin = async () => { const uri = bunkerUri.trim(); - if (!uri.startsWith('bunker://')) { - setBunkerError('Must start with bunker://'); - return; - } + 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(); + onLogin(); onClose(); } catch (e: unknown) { - setBunkerError( - `Connection failed: ${(e as Error)?.message ?? 'Check your bunker URI and try again.'}` - ); - } finally { - setIsLoading(false); + 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 a Nostr browser extension like Alby.', variant: 'destructive' }); - } + 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: 'Paste in Amber → Scan/Paste link' }); + toast({ title: 'Copied!', description: 'Open Amber → Scan / Paste link → paste it' }); }; const openAmberWithUri = () => { - if (nostrConnectUri) { - try { window.open(nostrConnectUri, '_blank'); } catch { copyUri(); } - } + if (nostrConnectUri) { try { window.open(nostrConnectUri, '_blank'); } catch { copyUri(); } } }; return ( @@ -325,68 +362,59 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog New here?

Create a free account. No email required.

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

Login with Amber

-

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

+

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

-

- Need Amber?{' '} - Download → -

)} - {amberStep === 'connecting' && ( + {amberStep === 'waiting' && (
@@ -396,46 +424,33 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog

In Amber:

-

1. Open Amber

-

2. Tap Scan / Paste link

-

3. Paste the connection string

-

4. Tap Approve

+

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. +

+ )} +
- -
@@ -455,49 +470,48 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog
-

Bunker Login (NIP-46)

-

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

+

Bunker (NIP-46)

+

Paste your bunker:// URI.

-
-

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} - /> + { 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 ── */}
@@ -506,10 +520,7 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog

Browser Extension (NIP-07)

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

-