diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index 6e2f422..08e5dc4 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -1,8 +1,8 @@ // 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 React, { useState, useEffect, useRef } from 'react'; +import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, 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"; @@ -10,10 +10,7 @@ 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 { SimplePool, generateSecretKey, getPublicKey, nip19, nip04 } from 'nostr-tools'; +import { openAmberForLogin } from '@/lib/amber'; interface LoginDialogProps { isOpen: boolean; @@ -22,68 +19,6 @@ interface LoginDialogProps { 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(''); @@ -91,265 +26,90 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog const [nsecInput, setNsecInput] = useState(''); const [nsecError, setNsecError] = useState(''); - const [amberStep, setAmberStep] = useState<'idle' | 'connecting' | 'waiting' | 'error' | 'success'>('idle'); - const [amberStatus, setAmberStatus] = useState(''); - const [amberElapsed, setAmberElapsed] = useState(0); - 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 pollIntervalRef = useRef | null>(null); - const elapsedIntervalRef = useRef | null>(null); + // Amber NIP-55 state — just waiting for callback, no relay nonsense + const [amberWaiting, setAmberWaiting] = useState(false); + const [amberDone, setAmberDone] = useState(false); + const pollRef = useRef | null>(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?.(); - const cleanup = useCallback(() => { - try { subRef.current?.close(); } catch { /* ignore */ } - try { poolRef.current?.close(NOSTRCONNECT_RELAYS); } catch { /* ignore */ } - subRef.current = null; - poolRef.current = null; - if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; } - if (elapsedIntervalRef.current) { clearInterval(elapsedIntervalRef.current); elapsedIntervalRef.current = null; } - }, []); - - // Complete login — store as 'amber' type (NIP-55), not bunker. - // Amber signs as the user's own key, so signerPubkey IS the user's pubkey. - const completeLogin = useCallback(async (signerPubkey: string) => { - setAmberStatus('Logging in...'); - console.log('[Amber] Completing login for pubkey:', signerPubkey); - - // Store as amber login type — picked up by useCurrentUser → AmberSigner - const nlogin = new NLogin('amber' as Parameters[0], signerPubkey); - 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]); - - // Poll relay once — returns true if Amber's response was found and login completed - const pollRelay = useCallback(async (): Promise => { - const pending = pendingRef.current || loadPendingNC(); - if (!pending || amberStepRef.current !== 'waiting') return false; - - console.log('[Amber] Polling relay for response...'); - setAmberStatus('Checking relay for Amber response...'); - - try { - const decoded = nip19.decode(pending.clientNsec); - if (decoded.type !== 'nsec') return false; - const clientSk = decoded.data; - const since = Math.floor(pending.timestamp / 1000) - 30; - const freshPool = new SimplePool(); - let found = false; - - await new Promise((resolve) => { - const timeout = setTimeout(() => { freshPool.close(NOSTRCONNECT_RELAYS); resolve(); }, 6000); - - const sub = freshPool.subscribeMany( - NOSTRCONNECT_RELAYS, - [{ kinds: [24133], '#p': [pending.clientPubkey], since }], - { - onevent: async (event) => { - if (found) return; - console.log('[Amber] Poll 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 }; - 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); - resolve(); - } catch { /* wrong encryption key or malformed event */ } - }, - oneose: () => { - setTimeout(() => { sub.close(); freshPool.close(NOSTRCONNECT_RELAYS); resolve(); }, 1500); - }, - } - ); - }); - - if (!found && amberStepRef.current === 'waiting') { - setAmberStatus('Nothing yet — still waiting for Amber...'); + // Poll localStorage for the pubkey that AmberCallbackPage writes when Amber returns + const startPolling = () => { + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = setInterval(() => { + const pubkey = localStorage.getItem('amber_pending_pubkey'); + if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey)) { + localStorage.removeItem('amber_pending_pubkey'); + clearInterval(pollRef.current!); + pollRef.current = null; + console.log('[Amber] Got pubkey from callback:', pubkey); + try { + login.amber(pubkey); + setAmberDone(true); + setAmberWaiting(false); + toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' }); + setTimeout(() => { onLogin(); onClose(); }, 800); + } catch (e) { + console.error('[Amber] login.amber() failed:', e); + setAmberWaiting(false); + toast({ title: 'Login failed', description: 'Could not log in with Amber. Try again.', variant: 'destructive' }); + } } - return found; - } catch { return false; } - }, [completeLogin]); + }, 500); + }; - // When app returns from Amber: immediately poll, then keep polling every 3s + const handleAmberLogin = () => { + console.log('[Amber] Opening Amber for NIP-55 get_public_key...'); + localStorage.removeItem('amber_pending_pubkey'); // clear any stale value + openAmberForLogin(); + setAmberWaiting(true); + startPolling(); + }; + + // Also detect when user comes back to the app (visibilitychange) useEffect(() => { - const handleVisibility = async () => { - if (document.visibilityState !== 'visible') return; - if (amberStepRef.current !== 'waiting') return; - - console.log('[Amber] App foregrounded — starting poll loop'); - - // Poll immediately on return - const found = await pollRelay(); - if (found) return; - - // Then poll every 3 seconds until success or timeout/cancel clears the interval - if (!pollIntervalRef.current) { - pollIntervalRef.current = setInterval(async () => { - if (amberStepRef.current !== 'waiting') { - clearInterval(pollIntervalRef.current!); - pollIntervalRef.current = null; - return; + if (!amberWaiting) return; + const handleVisible = () => { + if (document.visibilityState === 'visible') { + console.log('[Amber] App foregrounded — checking for pubkey'); + const pubkey = localStorage.getItem('amber_pending_pubkey'); + if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey)) { + localStorage.removeItem('amber_pending_pubkey'); + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + try { + login.amber(pubkey); + setAmberDone(true); + setAmberWaiting(false); + toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' }); + setTimeout(() => { onLogin(); onClose(); }, 800); + } catch (e) { + console.error('[Amber] login.amber() failed:', e); + setAmberWaiting(false); + toast({ title: 'Login failed', description: 'Could not log in with Amber. Try again.', variant: 'destructive' }); } - const done = await pollRelay(); - if (done) { - clearInterval(pollIntervalRef.current!); - pollIntervalRef.current = null; - } - }, 3000); + } } }; - - document.addEventListener('visibilitychange', handleVisibility); - return () => document.removeEventListener('visibilitychange', handleVisibility); - }, [pollRelay]); + document.addEventListener('visibilitychange', handleVisible); + return () => document.removeEventListener('visibilitychange', handleVisible); + }, [amberWaiting, login, onLogin, onClose, toast]); // Reset when dialog closes useEffect(() => { if (!isOpen) { - abortRef.current?.abort(); - abortRef.current = null; - cleanup(); - amberStepRef.current = 'idle'; - setAmberStep('idle'); - setAmberStatus(''); - setAmberElapsed(0); - setNostrConnectUri(''); + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + setAmberWaiting(false); + setAmberDone(false); 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('Approve in Amber, then come back here.'); - setAmberElapsed(0); - if (elapsedIntervalRef.current) clearInterval(elapsedIntervalRef.current); - elapsedIntervalRef.current = setInterval(() => { - setAmberElapsed(s => s + 1); - }, 1000); - - // 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); - } catch (e) { - console.warn('[Amber] Failed to decrypt event:', e); - } - }, - } - ); - subRef.current = sub; - - // On Capacitor (native app) give 3 minutes — the visibility change handler does the real work - // after the user returns from Amber, so the live subscription just needs to stay alive. - // On web/PWA the live subscription fires instantly, so 3 min is still fine as a safety net. - const TIMEOUT_MS = isCapacitor ? 3 * 60 * 1000 : 3 * 60 * 1000; - setTimeout(() => { - if (!controller.signal.aborted) { - console.warn('[Amber] Timed out — 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?'); - } - } - }, TIMEOUT_MS); - }, [cleanup, completeLogin, toast]); + }, [isOpen]); const handleBunkerLogin = async () => { const uri = bunkerUri.trim(); @@ -382,15 +142,6 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog 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 ( @@ -440,96 +191,54 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog {/* ── AMBER TAB ── */} - {(amberStep === 'idle' || amberStep === 'connecting') && ( + {/* Success */} + {amberDone && ( +
+ +

Logged in!

+ +
+ )} + + {/* Waiting for Amber to return */} + {!amberDone && amberWaiting && ( +
+ +
+

Waiting for Amber...

+

Approve in Amber, then come back here.

+
+ +
+ )} + + {/* Idle — show login button */} + {!amberDone && !amberWaiting && (

Login with Amber

-

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

+

NIP-55 Android signer. Tap, approve in Amber, done.

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

⏱ Timed out

- : - } -

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

-

{amberStatus}

- {amberStep === 'waiting' && ( -

- {Math.floor(amberElapsed / 60)}:{String(amberElapsed % 60).padStart(2, '0')} elapsed -

- )} -
- -
-

In Amber:

-

1. Tap Scan / Paste link

-

2. Paste the connection string below

-

3. Tap Approve

-

4. Come back here

-
- -
- - -
- {amberStep === 'waiting' && ( - - )} - - {isCapacitor && ( -

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

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

Logged in!

- -
- )}
{/* ── BUNKER TAB ── */} diff --git a/src/hooks/useLoginActions.ts b/src/hooks/useLoginActions.ts index 776621b..a781caf 100644 --- a/src/hooks/useLoginActions.ts +++ b/src/hooks/useLoginActions.ts @@ -7,6 +7,8 @@ import { // NOTE: This file should not be edited except for adding new login methods. +export type AmberLoginType = { id: string; type: 'amber'; pubkey: string }; + export function useLoginActions() { const { nostr } = useNostr(); const { logins, addLogin, setLogin, removeLogin } = useNostrLogin(); @@ -32,6 +34,16 @@ export function useLoginActions() { const login = await NLogin.fromExtension(); addAndActivate(login); }, + // Login with Amber (NIP-55) — pubkey comes from the AmberCallbackPage + amber(pubkey: string): void { + const amberLogin: AmberLoginType = { + id: `amber:${pubkey}`, + type: 'amber', + pubkey, + }; + addLogin(amberLogin as unknown as NLoginType); + setLogin(amberLogin.id); + }, // Log out the current user async logout(): Promise { const login = logins[0];