// 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, { useRef, useState, useEffect, useCallback } from 'react'; import { Upload, AlertTriangle, ChevronDown, ChevronUp, Loader2, Copy, Check, ExternalLink } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; import { QRCodeCanvas } from '@/components/ui/qrcode'; import { useLoginActions, generateNostrConnectParams, generateNostrConnectURI, type NostrConnectParams, } from '@/hooks/useLoginActions'; import { DialogTitle } from '@radix-ui/react-dialog'; import { useIsMobile } from '@/hooks/useIsMobile'; interface LoginDialogProps { isOpen: boolean; onClose: () => void; onLogin: () => void; } const validateNsec = (nsec: string) => { return /^nsec1[a-zA-Z0-9]{58}$/.test(nsec); }; const validateBunkerUri = (uri: string) => { return uri.startsWith('bunker://'); }; /** Check if running on actual mobile device (not just small screen) */ function isMobileDevice(): boolean { if (typeof navigator === 'undefined') return false; return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); } const LoginDialog: React.FC = ({ isOpen, onClose, onLogin }) => { const [isLoading, setIsLoading] = useState(false); const [isFileLoading, setIsFileLoading] = useState(false); const [nsec, setNsec] = useState(''); const [bunkerUri, setBunkerUri] = useState(''); const [nostrConnectParams, setNostrConnectParams] = useState(null); const [nostrConnectUri, setNostrConnectUri] = useState(''); const [isWaitingForConnect, setIsWaitingForConnect] = useState(false); const [connectError, setConnectError] = useState(null); const [copied, setCopied] = useState(false); const [showBunkerInput, setShowBunkerInput] = useState(false); const [errors, setErrors] = useState<{ nsec?: string; bunker?: string; file?: string; extension?: string; }>({}); const fileInputRef = useRef(null); const abortControllerRef = useRef(null); const login = useLoginActions(); // Check if on mobile device const isMobile = useIsMobile(); // Check if extension is available const hasExtension = 'nostr' in window; // Generate nostrconnect params (sync) - just creates the QR code data const generateConnectSession = useCallback(() => { const relayUrls = login.getRelayUrls(); const params = generateNostrConnectParams(relayUrls); const uri = generateNostrConnectURI(params, { callback: isMobileDevice() ? `${window.location.origin}/remoteloginsuccess` : undefined, }); setNostrConnectParams(params); setNostrConnectUri(uri); setConnectError(null); }, [login]); // Start listening for connection (async) - runs after params are set useEffect(() => { if (!nostrConnectParams || isWaitingForConnect) return; const startListening = async () => { setIsWaitingForConnect(true); abortControllerRef.current = new AbortController(); try { await login.nostrconnect(nostrConnectParams, abortControllerRef.current.signal); onLogin(); onClose(); } catch (error) { console.error('Nostrconnect failed:', error); // Don't show error if it was aborted (dialog closed) if (error instanceof Error && error.name !== 'AbortError') { setConnectError(error.message); } setIsWaitingForConnect(false); } }; startListening(); }, [nostrConnectParams, login, onLogin, onClose, isWaitingForConnect]); // Clean up on close useEffect(() => { if (!isOpen) { setNostrConnectParams(null); setNostrConnectUri(''); setIsWaitingForConnect(false); setConnectError(null); setNsec(''); setBunkerUri(''); setErrors({}); setIsLoading(false); setIsFileLoading(false); setShowBunkerInput(false); if (abortControllerRef.current) { abortControllerRef.current.abort(); } if (fileInputRef.current) { fileInputRef.current.value = ''; } } }, [isOpen]); // Retry connection with new params const handleRetry = useCallback(() => { setNostrConnectParams(null); setNostrConnectUri(''); setIsWaitingForConnect(false); setConnectError(null); // Generate new session after state clears setTimeout(() => generateConnectSession(), 0); }, [generateConnectSession]); const handleCopyUri = async () => { await navigator.clipboard.writeText(nostrConnectUri); setCopied(true); setTimeout(() => setCopied(false), 2000); }; // Open the nostrconnect URI in the system - this will launch a signer app like Amber if installed const handleOpenSignerApp = () => { if (!nostrConnectUri) return; window.location.href = nostrConnectUri; }; 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('Extension login failed:', error); setErrors(prev => ({ ...prev, extension: error instanceof Error ? error.message : 'Extension login failed' })); } finally { setIsLoading(false); } }; const executeLogin = (key: string) => { setIsLoading(true); setErrors({}); // Use a timeout to allow the UI to update before the synchronous login call setTimeout(() => { try { login.nsec(key); onLogin(); onClose(); } catch { setErrors({ nsec: "Failed to login with this key. Please check that it's correct." }); setIsLoading(false); } }, 50); }; const handleKeyLogin = () => { if (!nsec.trim()) { setErrors(prev => ({ ...prev, nsec: 'Please enter your secret key' })); return; } if (!validateNsec(nsec)) { setErrors(prev => ({ ...prev, nsec: 'Invalid secret key format. Must be a valid nsec starting with nsec1.' })); return; } executeLogin(nsec); }; 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 handleFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setIsFileLoading(true); setErrors({}); const reader = new FileReader(); reader.onload = (event) => { setIsFileLoading(false); const content = event.target?.result as string; if (content) { const trimmedContent = content.trim(); if (validateNsec(trimmedContent)) { executeLogin(trimmedContent); } else { setErrors({ file: 'File does not contain a valid secret key.' }); } } else { setErrors({ file: 'Could not read file content.' }); } }; reader.onerror = () => { setIsFileLoading(false); setErrors({ file: 'Failed to read file.' }); }; reader.readAsText(file); }; const [isMoreOptionsOpen, setIsMoreOptionsOpen] = useState(false); const renderTabs = () => ( { if (value === 'remote' && !nostrConnectParams && !connectError) { generateConnectSession(); } }} > Secret Key Remote Signer
{ e.preventDefault(); handleKeyLogin(); }} className='space-y-4'>
{ setNsec(e.target.value); if (errors.nsec) setErrors(prev => ({ ...prev, nsec: undefined })); }} className={`rounded-lg ${ errors.nsec ? 'border-red-500 focus-visible:ring-red-500' : '' }`} placeholder='nsec1...' autoComplete="off" /> {errors.nsec && (

{errors.nsec}

)}
{errors.file && (

{errors.file}

)}
{/* Nostrconnect Section */}
{connectError ? (

{connectError}

) : nostrConnectUri ? ( <> {/* QR Code - only show on desktop */} {!isMobile && (
)} {/* Status message */}
{isMobile ? 'Tap to open your signer app' : 'Scan with your signer app'}
{/* Open Signer App button - primary action on mobile */} {isMobile && ( )} {/* Copy button */} ) : (
)}
{/* Manual URI input section - collapsible */}
{showBunkerInput && (
setBunkerUri(e.target.value)} className='rounded-lg border-gray-300 dark:border-gray-700 focus-visible:ring-primary text-base md:text-sm' placeholder='bunker://' /> {bunkerUri && !validateBunkerUri(bunkerUri) && (

Invalid bunker URI format

)}
)}
); return ( Log in
🔑
{/* Extension Login Button - shown if extension is available */} {hasExtension && (
{errors.extension && ( {errors.extension} )}
)} {/* Tabs - wrapped in collapsible if extension is available, otherwise shown directly */} {hasExtension ? ( {renderTabs()} ) : ( renderTabs() )}
); }; export default LoginDialog;