// 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 } from 'react'; // Extend window with a cleanup ref for Amber visibilitychange listener declare global { interface Window { __amberUnsubRef?: () => void; } } 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"; 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 { openAmberForLogin, subscribeToAmberReturn } from '@/lib/amber'; import { getAmberPublicKeyViaPlugin } from '@/lib/capacitorAmberSigner'; interface LoginDialogProps { isOpen: boolean; onClose: () => void; onLogin: () => void; onSignup?: () => void; } 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 NIP-55 state const [amberStep, setAmberStep] = useState<'idle' | 'loading' | 'paste' | 'done' | 'error'>('idle'); const [amberError, setAmberError] = useState(''); const login = useLoginActions(); const { toast } = useToast(); const isAndroid = /Android/i.test(navigator.userAgent); // Detect if running inside a Capacitor native app const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.(); // Step 1: open Amber // - On Capacitor (ZapStore APK): use the native plugin — direct Promise, no redirects needed // - On web/PWA: use intent:// URL with callbackUrl so Amber redirects back const handleAmberLogin = async () => { setAmberStep('loading'); setAmberError(''); if (isCapacitor) { // ── Native path (ZapStore/Capacitor APK) ──────────────────────────────── // Attempt login directly — skip isExternalSignerInstalled() check because // Android 11+ package visibility rules can make it return false even when // Amber IS installed (requires in AndroidManifest.xml). try { // This opens Amber natively, user approves, pubkey comes back as a Promise const pubkey = await getAmberPublicKeyViaPlugin(); completeAmberLogin(pubkey); } catch (e: unknown) { const msg = (e as Error)?.message ?? 'Amber login failed'; // Distinguish: user cancelled vs Amber not installed vs other error if (msg.toLowerCase().includes('cancel') || msg.toLowerCase().includes('denied')) { setAmberError('Login cancelled. Tap "Open Amber" to try again.'); } else if (msg.toLowerCase().includes('not found') || msg.toLowerCase().includes('no activity') || msg.toLowerCase().includes('resolve')) { setAmberError('Amber not found. Make sure Amber is installed from the ZapStore.'); } else { setAmberError(msg); } setAmberStep('error'); } return; } // ── Web/PWA path ───────────────────────────────────────────────────────── // Store return path before navigating away const currentPath = window.location.hash.replace(/^#/, '') || '/'; localStorage.setItem('amber_return_path', currentPath); // Listen for app regaining focus (after Amber redirect) const unsub = subscribeToAmberReturn(() => { checkAmberPendingPubkey(); }); window.__amberUnsubRef = unsub; openAmberForLogin(); }; // Check if AmberCallbackPage already wrote a pubkey to localStorage // (happens when Amber opens the callback in a new browser tab on Android) const checkAmberPendingPubkey = () => { const storedPubkey = localStorage.getItem('amber_pending_pubkey'); if (storedPubkey && /^[0-9a-f]{64}$/i.test(storedPubkey)) { localStorage.removeItem('amber_pending_pubkey'); completeAmberLogin(storedPubkey.toLowerCase()); return; } // Also try clipboard as fallback tryClipboardForPubkey(); }; // Try reading the pubkey from clipboard (Amber may have copied it there) const tryClipboardForPubkey = async () => { try { const text = (await navigator.clipboard.readText()).trim(); if (/^[0-9a-f]{64}$/i.test(text)) { completeAmberLogin(text.toLowerCase()); return; } if (text.startsWith('npub1')) { const { nip19 } = await import('nostr-tools'); const decoded = nip19.decode(text); if (decoded.type === 'npub') { completeAmberLogin(decoded.data as string); return; } } } catch { /* clipboard denied — fall through to manual paste */ } // Couldn't auto-detect — show the manual paste UI setAmberStep('paste'); }; // Poll for amber_pending_pubkey (written by AmberCallbackPage in a separate tab) useEffect(() => { if (amberStep !== 'loading') return; const interval = setInterval(() => { const storedPubkey = localStorage.getItem('amber_pending_pubkey'); if (storedPubkey && /^[0-9a-f]{64}$/i.test(storedPubkey)) { localStorage.removeItem('amber_pending_pubkey'); clearInterval(interval); completeAmberLogin(storedPubkey.toLowerCase()); } }, 500); // Stop polling after 3 minutes const timeout = setTimeout(() => { clearInterval(interval); if (amberStep === 'loading') setAmberStep('paste'); }, 180_000); return () => { clearInterval(interval); clearTimeout(timeout); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [amberStep]); const completeAmberLogin = (pubkey: string) => { try { login.amber(pubkey); setAmberStep('done'); toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' }); setTimeout(() => { onLogin(); onClose(); }, 800); } catch (e: unknown) { setAmberError((e as Error)?.message ?? 'Login failed'); setAmberStep('error'); } }; const [pubkeyInput, setPubkeyInput] = useState(''); const [pubkeyError, setPubkeyError] = useState(''); const handlePubkeySubmit = async () => { const val = pubkeyInput.trim(); // Accept hex pubkey or npub if (/^[0-9a-f]{64}$/i.test(val)) { completeAmberLogin(val.toLowerCase()); return; } if (val.startsWith('npub1')) { try { const { nip19 } = await import('nostr-tools'); const decoded = nip19.decode(val); if (decoded.type === 'npub') { completeAmberLogin(decoded.data as string); return; } } catch { /* fall through */ } } setPubkeyError('Paste your hex pubkey (64 chars) or npub from Amber'); }; // Reset when dialog closes useEffect(() => { if (!isOpen) { // Clean up any pending Amber visibilitychange listener window.__amberUnsubRef?.(); window.__amberUnsubRef = undefined; setAmberStep('idle'); setAmberError(''); setPubkeyInput(''); setPubkeyError(''); setBunkerUri(''); setBunkerError(''); setNsecInput(''); setNsecError(''); setIsLoading(false); } }, [isOpen]); 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' }); } }; 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 ── */} {/* Success */} {amberStep === 'done' && (

Logged in!

)} {/* Idle / loading — open Amber button */} {(amberStep === 'idle' || amberStep === 'loading') && (
{amberStep === 'loading' ? : }

Login with Amber

Opens Amber → approve → come back here.

)} {/* Paste pubkey — after returning from Amber */} {(amberStep === 'paste' || amberStep === 'error') && (

Paste your pubkey

In Amber: tap your profile → Copy public key. Then paste below.

{amberStep === 'error' && amberError && (

{amberError}

)}
{ setPubkeyInput(e.target.value); setPubkeyError(''); }} className='bg-black/60 border-[#ccff00]/30 text-white placeholder:text-gray-600 text-xs font-mono' autoComplete='off' autoCorrect='off' spellCheck={false} /> {pubkeyError &&

{pubkeyError}

}
)}
{/* ── 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;