import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Upload, ChevronDown, ChevronUp, ArrowLeft, ArrowRight, Loader2, ExternalLink, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { QRCodeCanvas } from '@/components/ui/qrcode'; import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon'; import { useAppContext } from '@/hooks/useAppContext'; import { useLoginActions, generateNostrConnectParams, generateNostrConnectURI, type NostrConnectParams, type NostrConnectStatus, } from '@/hooks/useLoginActions'; import { useIsMobile } from '@/hooks/useIsMobile'; import { useToast } from '@/hooks/useToast'; import { useOnboarding } from '@/contexts/onboardingContextDef'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; interface AuthDialogProps { isOpen: boolean; onClose: () => void; } /** * The dialog covers the three login paths only — nsec, NIP-07 extension, and * NIP-46 (nostrconnect QR + bunker URI). New-account signup is handled by * the captive `` flow; the welcome step's "Create" button * closes this dialog and hands off to that flow. */ type Step = 'welcome' | 'login' | 'connect'; const validateNsec = (nsec: string) => /^nsec1[a-zA-Z0-9]{58}$/.test(nsec); const validateBunkerUri = (uri: string) => uri.startsWith('bunker://'); /** Check if running on an actual mobile device (not just a small screen). */ function isMobileDevice(): boolean { if (typeof navigator === 'undefined') return false; return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); } const AuthDialog: React.FC = ({ isOpen, onClose }) => { const [step, setStep] = useState('welcome'); // Login state const [loginNsec, setLoginNsec] = useState(''); const [isLoggingIn, setIsLoggingIn] = useState(false); const [loginError, setLoginError] = useState(''); // Nostrconnect / bunker state const [nostrConnectParams, setNostrConnectParams] = useState(null); const [nostrConnectUri, setNostrConnectUri] = useState(''); const [connectError, setConnectError] = useState(null); // Progress status for the nostrconnect handshake. `null` means the user // hasn't kicked off the handshake yet (or they canceled) — we show the QR // / "Open signer app" button. Once the handshake advances we swap in a // spinner with a live status line so the user knows something is working. const [connectStatus, setConnectStatus] = useState(null); // Tracks whether the user has explicitly initiated the handshake from the // mobile UI. The listen subscription itself starts the moment params are // generated — without this flag we'd flip into the progress view as soon // as the user enters the Remote Signer step, before they've done anything. // Desktop doesn't need this: it stays on the QR until the handshake // advances past `awaiting-connect`. const [hasOpenedSigner, setHasOpenedSigner] = useState(false); const [showBunkerInput, setShowBunkerInput] = useState(false); const [bunkerUri, setBunkerUri] = useState(''); const login = useLoginActions(); const { config } = useAppContext(); const { startSignup } = useOnboarding(); const { toast } = useToast(); const { t } = useTranslation(); const connectStatusLabel = (status: NostrConnectStatus | null): string => { switch (status) { case 'awaiting-connect': return t('auth.waitingForSigner'); case 'getting-public-key': return t('auth.gettingPublicKey'); default: return ''; } }; // Stable refs so the nostrconnect listening effect below doesn't restart on // every parent render. Parents typically pass inline arrow functions for // onClose, and useLoginActions returns a fresh object each render — without // stable refs, an effect depending on them would tear down the in-flight // subscription on every render and cause approved logins to be swallowed. const loginRef = useRef(login); const onCloseRef = useRef(onClose); useEffect(() => { loginRef.current = login; }, [login]); useEffect(() => { onCloseRef.current = onClose; }, [onClose]); const fileInputRef = useRef(null); const abortControllerRef = useRef(null); const isMobile = useIsMobile(); const hasExtension = typeof window !== 'undefined' && 'nostr' in window; // Reset state when the dialog closes. // This is the "reset state when a prop changes" pattern; the usual // React-preferred alternative is a `key` prop on the caller, but the // public API of this component is a simple open/close boolean, so we // reset here. The multiple setState calls are intentional. useEffect(() => { if (!isOpen) { setStep('welcome'); setLoginNsec(''); setIsLoggingIn(false); setLoginError(''); setNostrConnectParams(null); setNostrConnectUri(''); setConnectError(null); setConnectStatus(null); setHasOpenedSigner(false); setShowBunkerInput(false); setBunkerUri(''); abortControllerRef.current?.abort(); abortControllerRef.current = null; } }, [isOpen]); // Generate a nostrconnect session (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 a nostrconnect response once params are set. // // Deps are intentionally limited to `nostrConnectParams` so that parent // re-renders (which produce fresh onClose closures and a fresh `login` // object from useLoginActions) do NOT tear down an in-flight // subscription. An earlier version used a `cancelled` flag flipped by // the effect's cleanup, which caused a successful nostrconnect response // to be silently swallowed after the signer approved — the subscription // was re-created mid-handshake and the first instance's success branch // saw `cancelled === true`. // // Cancellation is handled explicitly by the `isOpen` effect (on dialog // close) and by handleConnectRetry() (on user cancel/retry). useEffect(() => { if (!nostrConnectParams) return; const startListening = async () => { const controller = new AbortController(); abortControllerRef.current = controller; try { await loginRef.current.nostrconnect( nostrConnectParams, controller.signal, (status) => { if (controller.signal.aborted) return; setConnectStatus(status); }, ); // If the dialog was explicitly closed (handled by the isOpen // effect, which aborts the controller), don't try to re-close it. // Otherwise the user is logged in — close the dialog. if (controller.signal.aborted) return; onCloseRef.current(); } catch (error) { // AbortError means we intentionally aborted (dialog closed or retry) if (error instanceof Error && error.name === 'AbortError') return; if (controller.signal.aborted) return; console.error('Nostrconnect failed:', error); setConnectStatus(null); setConnectError(error instanceof Error ? error.message : String(error)); } }; startListening(); // No cleanup here: we do NOT want a re-render-triggered effect teardown // to cancel the in-flight subscription. }, [nostrConnectParams]); const handleConnectRetry = useCallback(() => { abortControllerRef.current?.abort(); setNostrConnectParams(null); setNostrConnectUri(''); setConnectError(null); setConnectStatus(null); setHasOpenedSigner(false); setTimeout(() => generateConnectSession(), 0); }, [generateConnectSession]); const handleOpenSignerApp = () => { if (!nostrConnectUri) return; // Flip into the progress view *synchronously* before navigating so that // when the user returns from the signer app, the dialog is already // showing "Waiting for signer connection…" — not the original button // they're worried they need to re-tap. setHasOpenedSigner(true); window.location.href = nostrConnectUri; }; const handleBunkerLogin = async () => { if (!bunkerUri.trim() || !validateBunkerUri(bunkerUri)) return; setIsLoggingIn(true); try { await login.bunker(bunkerUri); onClose(); } catch { setConnectError(t('auth.errorBunkerConnect')); setIsLoggingIn(false); } }; const goToConnect = () => { setStep('connect'); if (!nostrConnectParams && !connectError) { generateConnectSession(); } }; /** * Hand off from this login-focused dialog to the captive signup flow. * Closes the dialog first so the captive overlay isn't competing with a * still-open dialog (the captive overlay's z-50 would visually win, but * leaving the dialog mounted means a stale "welcome" step would flash * when the captive flow finishes and the dialog re-opens for any reason). */ const goToSignup = useCallback(() => { onClose(); startSignup(); }, [onClose, startSignup]); // Login: submit the entered nsec. const handleLogin = () => { if (!loginNsec.trim()) { setLoginError(t('auth.errorEnterSecretKey')); return; } if (!validateNsec(loginNsec)) { setLoginError(t('auth.errorInvalidSecretKey')); return; } setIsLoggingIn(true); setLoginError(''); // Timeout gives the UI a chance to repaint before the synchronous login. setTimeout(() => { try { login.nsec(loginNsec); onClose(); } catch { setLoginError(t('auth.errorLoginFailed')); setIsLoggingIn(false); } }, 50); }; const handleFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { const content = event.target?.result as string; if (content && validateNsec(content.trim())) { setLoginNsec(content.trim()); } else { setLoginError(t('auth.errorFileNoKey')); } }; reader.onerror = () => setLoginError(t('auth.errorFileRead')); reader.readAsText(file); }; const handleExtensionLogin = async () => { if (!hasExtension) return; setIsLoggingIn(true); try { await login.extension(); onClose(); } catch (e) { toast({ variant: 'destructive', title: t('auth.extensionErrorTitle'), description: e instanceof Error ? e.message : t('auth.errorExtensionFailed'), }); setIsLoggingIn(false); } }; const getTitle = () => { switch (step) { case 'login': return t('auth.loginTitle'); case 'connect': return t('auth.connectSignerTitle'); default: return ''; } }; // Decide whether to render the progress view in place of the QR/button. // Mobile: flip in as soon as the user taps "Open signer app" (tracked by // `hasOpenedSigner`) so they see feedback the moment they return from the // signer. Desktop: keep the QR visible through the `awaiting-connect` // phase (it's still actionable — they might scan with another device) and // only swap in once the signer has acknowledged and we're fetching the // pubkey. const showProgressView = connectStatus !== null && ( connectStatus === 'getting-public-key' || (isMobile && hasOpenedSigner) ); return ( {step === 'welcome' ? ( /* Welcome step — the unified entry point. The Agora logo and wordmark are the focal point (and double as the dialog's accessible title), so there's no separate header row or subtext — just the brand and the two paths in. */
{config.appName}
) : ( <>
{getTitle()}
{/* Login step. The secret-key form is always visible and first, followed by the extension button (when available), then a text link out to the remote-signer (connect) step. */} {step === 'login' && (
{hasExtension && ( )}
)} {/* Connect step — nostrconnect QR + bunker URI fallback. */} {step === 'connect' && (
{connectError ? (

{connectError}

) : showProgressView ? ( // Progress view — replaces the QR/button once the handshake // is under way. Gives the user live feedback through each // phase so a stuck signer is visibly stuck, not silently // stuck.

{connectStatusLabel(connectStatus)}

) : nostrConnectUri ? ( <> {!isMobile && (
)} {isMobile && ( )} ) : (
)}
{/* Manual bunker URI fallback. */} {t('auth.enterBunkerManually')} {showBunkerInput ? ( ) : ( )} setBunkerUri(e.target.value)} placeholder={t('auth.bunkerPlaceholder')} className="text-base md:text-sm" /> {bunkerUri && !validateBunkerUri(bunkerUri) && ( {t('auth.invalidBunkerFormat')} )}
)}
)}
); }; /** Shared nsec input + submit + file-upload block used in the login step. */ interface NsecLoginFormProps { loginNsec: string; setLoginNsec: (v: string) => void; loginError: string; setLoginError: (v: string) => void; isLoggingIn: boolean; onSubmit: () => void; onFileChange: (e: React.ChangeEvent) => void; fileInputRef: React.RefObject; t: (key: string) => string; } const NsecLoginForm: React.FC = ({ loginNsec, setLoginNsec, loginError, setLoginError, isLoggingIn, onSubmit, onFileChange, fileInputRef, t, }) => (
{ e.preventDefault(); onSubmit(); }} className="space-y-3" data-nsec-allowed > {t('auth.secretKeyLabel')} {/* nsec input and the key-file upload icon share a row. */}
{ setLoginNsec(e.target.value); if (loginError) setLoginError(''); }} placeholder={t('auth.nsecPlaceholder')} autoComplete="off" className={cn( 'flex-1', loginError && 'border-destructive focus-visible:ring-destructive', )} />
{loginError &&

{loginError}

}
); export default AuthDialog;