// 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 } from 'react'; import { Shield, AlertTriangle, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; // used for bunker URI input import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { useLoginActions } from '@/hooks/useLoginActions'; import { useToast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; import { subscribeToAmberReturn } from '@/lib/amber'; interface LoginDialogProps { isOpen: boolean; onClose: () => void; onLogin: () => void; onSignup?: () => void; } const validateBunkerUri = (uri: string) => { return uri.startsWith('bunker://'); }; const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onSignup }) => { const [isLoading, setIsLoading] = useState(false); const [bunkerUri, setBunkerUri] = useState(''); const [errors, setErrors] = useState<{ bunker?: string; extension?: string; amber?: string; }>({}); const [amberStep, setAmberStep] = useState<'initial' | 'waiting' | 'success'>('initial'); const unsubAmberRef = useRef<(() => void) | null>(null); const pollRef = useRef | null>(null); const login = useLoginActions(); const { toast } = useToast(); // Reset all state when dialog opens/closes useEffect(() => { if (isOpen) { setIsLoading(false); setBunkerUri(''); setErrors({}); setAmberStep('initial'); } if (!isOpen) { // Clean up listeners when dialog closes if (unsubAmberRef.current) { unsubAmberRef.current(); unsubAmberRef.current = null; } if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } } }, [isOpen]); // Clean up on unmount useEffect(() => { return () => { if (unsubAmberRef.current) unsubAmberRef.current(); if (pollRef.current) clearInterval(pollRef.current); }; }, []); const handleExtensionLogin = async () => { setIsLoading(true); setErrors(prev => ({ ...prev, extension: undefined })); try { if (!('nostr' in window)) { throw new Error('Nostr extension not found. Please install a NIP-07 extension.'); } await login.extension(); onLogin(); onClose(); } catch (e: unknown) { const error = e as Error; console.error('Bunker login failed:', error); console.error('Nsec login failed:', error); console.error('Extension login failed:', error); setErrors(prev => ({ ...prev, extension: error instanceof Error ? error.message : 'Extension login failed' })); } finally { setIsLoading(false); } }; const handleAmberLogin = () => { setAmberStep('waiting'); setErrors(prev => ({ ...prev, amber: undefined })); // Use localStorage (not sessionStorage) — survives navigation on Android browsers localStorage.setItem('amber_return_path', window.location.pathname); // Stamp the time so we know this is a fresh attempt localStorage.setItem('amber_login_attempt_ts', String(Date.now())); // Poll localStorage every 400ms for 'amber_pending_pubkey'. // AmberCallbackPage writes this key when Amber approves — even if it runs // in a separate tab (GrapheneOS opens callback in new tab). This tab picks // it up, calls login.amber() here where React state is alive, and completes. if (pollRef.current) clearInterval(pollRef.current); pollRef.current = setInterval(() => { const pendingPubkey = localStorage.getItem('amber_pending_pubkey'); if (pendingPubkey) { // Consume it immediately so we don't process twice localStorage.removeItem('amber_pending_pubkey'); clearInterval(pollRef.current!); pollRef.current = null; try { login.amber(pendingPubkey); setAmberStep('success'); toast({ title: '🛹 Logged in', description: 'Welcome back via Amber!' }); setTimeout(() => { onLogin(); onClose(); }, 600); } catch (err) { console.error('[Amber] login.amber failed in poll:', err); setErrors(prev => ({ ...prev, amber: 'Login failed. Please try again.' })); setAmberStep('initial'); } } }, 400); // Also try visibilitychange for browsers that keep the tab alive unsubAmberRef.current = subscribeToAmberReturn(() => { setAmberStep(prev => prev === 'waiting' ? 'initial' : prev); unsubAmberRef.current = null; }); // Open Amber — it will call /amber-callback?event= on approve const callbackUrl = `${window.location.origin}/amber-callback?event=`; const amberUrl = `nostrsigner:?compressionType=none&returnType=signature&type=get_public_key&callbackUrl=${encodeURIComponent(callbackUrl)}`; window.location.href = amberUrl; }; const handleBunkerLogin = async () => { if (!bunkerUri.trim()) { setErrors(prev => ({ ...prev, bunker: 'Please enter a bunker URI' })); return; } if (!validateBunkerUri(bunkerUri)) { setErrors(prev => ({ ...prev, bunker: 'Invalid bunker URI format. Must start with bunker://' })); return; } setIsLoading(true); setErrors(prev => ({ ...prev, bunker: undefined })); try { await login.bunker(bunkerUri); onLogin(); onClose(); // Clear the URI from memory setBunkerUri(''); } catch { setErrors(prev => ({ ...prev, bunker: 'Failed to connect to bunker. Please check the URI.' })); } finally { setIsLoading(false); } }; const handleSignupClick = () => { onClose(); if (onSignup) { onSignup(); } }; // Detect device type for better UX const isAndroid = /Android/i.test(navigator.userAgent); const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent); const isMobile = isAndroid || isIOS; const hasExtension = typeof window !== 'undefined' && 'nostr' in window; // Detect best default tab based on device and browser capabilities const getDefaultTab = () => { // Android - Amber is the best option if (isAndroid) return 'amber'; // iOS - Bunker is recommended (no Amber support) if (isIOS) return 'bunker'; // Desktop with extension - use extension if (hasExtension) return 'extension'; // Desktop without extension - show extension tab with install prompt return 'extension'; }; const defaultTab = getDefaultTab(); return ( Sign up or log in to continue
{/* Prominent Sign Up Section */}
{/* Neon glow effect */}
New here?

Create a free account in seconds. No email required.

{/* Divider */}
Or log in
{/* Login Methods */} Extension Ext Amber Bunker Bnk {errors.extension && ( No extension detected. Try using the Create Account button above{isAndroid ? ', or use Amber on Android' : isIOS ? ', or use Bunker for iOS' : ''}. )}

{isMobile ? 'Browser Extension' : 'One-Click Login (Desktop)'}

{isMobile ? 'Works with mobile browsers that have Nostr extensions (e.g., Kiwi Browser + Alby).' : 'Best for desktop browsers with Alby, nos2x, or similar extensions installed.' }

{isIOS && ( Amber is Android-only. For iOS, use Bunker (nsec.app or Nostrum). )} {/* Initial state — single tap */} {amberStep === 'initial' && (

Login with Amber

Tap below — Amber opens, you approve once, and you're automatically logged in. No copy-paste needed.

Don't have Amber?{' '} Download on GitHub {' '}or{' '} F-Droid

)} {/* Waiting — user is in Amber */} {amberStep === 'waiting' && (

Waiting for Amber...

Approve the request in Amber, then come back here.

{/* Animated dots */}
{[0, 1, 2].map(i => ( ))}
{/* Fallback for Android browsers that navigate away — polling handles auto-complete, but this button is a manual escape hatch if the user lands back here stuck */}

Already approved in Amber?

{errors.amber && (

{errors.amber}

)}
)} {/* Success */} {amberStep === 'success' && (

Logged in!

)}
{isIOS && ( Recommended for iOS! Use nsec.app for the best experience on iPhone/iPad. )}

Login with Nostr Connect {isIOS && '(Best for iOS)'}

{isIOS ? 'Works on any device! Get a bunker:// connection string from nsec.app and paste it below.' : 'Access your account from any device using a remote signer (bunker). Your keys stay safe on the server.' }

{ setBunkerUri(e.target.value); if (errors.bunker) setErrors(prev => ({ ...prev, bunker: undefined })); }} className={`rounded-lg ${ errors.bunker ? 'border-red-500' : '' }`} placeholder='bunker://...' autoComplete="off" /> {errors.bunker && (

{errors.bunker}

)}

Get a bunker connection from:

); }; export default LoginDialog;