// 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'; // 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, QrCode, Copy, Unlock } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Tabs, TabsContent } 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, isNativeAmberAvailable } from '@/lib/capacitorAmberSigner'; import { useNostr } from '@nostrify/react'; import { QRCodeCanvas } from '@/components/ui/qrcode'; import { startNostrConnect, nostrConnectBunkerUri, type NostrConnectHandle } from '@/lib/nostrConnect'; import { decryptNcryptsec } from '@/lib/ncryptsec'; import { useTranslation } from '@/contexts/language'; interface LoginDialogProps { isOpen: boolean; onClose: () => void; onLogin: () => void; onSignup?: () => void; } export const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onSignup }) => { const { t } = useTranslation(); 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(''); // Whether the user explicitly opened the manual-paste escape hatch. The proper // NIP-55 redirect flow never needs this; it's only a "having trouble?" fallback. const [showManualPaste, setShowManualPaste] = useState(false); // NIP-46 nostrconnect — pair with a phone signer via QR (desktop → phone). const [connectSession, setConnectSession] = useState(null); const [connectState, setConnectState] = useState<'idle' | 'waiting' | 'error'>('idle'); const [connectError, setConnectError] = useState(''); const [copiedUri, setCopiedUri] = useState(false); const connectRef = useRef(null); // NIP-49 — open a password-protected key (ncryptsec). const [nsecMode, setNsecMode] = useState<'raw' | 'encrypted'>('raw'); const [ncryptsecInput, setNcryptsecInput] = useState(''); const [ncryptsecPassword, setNcryptsecPassword] = useState(''); const { nostr } = useNostr(); const login = useLoginActions(); const { toast } = useToast(); const isAndroid = /Android/i.test(navigator.userAgent); // Which login method panel is showing. The three method cards below switch it. const [loginTab, setLoginTab] = useState<'amber' | 'bunker' | 'nsec' | 'extension' | 'connect'>( isAndroid ? 'amber' : 'bunker', ); // 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(''); // Decide native-vs-web at CLICK TIME using the reliable Capacitor check from // the signer module (imported @capacitor/core), NOT window.Capacitor which // can be false at mount inside the WebView — that mismatch was causing the // APK to fall into the web redirect flow and never actually open Amber. if (isNativeAmberAvailable()) { // ── 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 ?? t('settingsUi.loginDialog.amberLoginFailed'); // Distinguish: user cancelled vs Amber not installed vs other error if (msg.toLowerCase().includes('cancel') || msg.toLowerCase().includes('denied')) { setAmberError(t('settingsUi.loginDialog.amberCancelled')); } else if (msg.toLowerCase().includes('not found') || msg.toLowerCase().includes('no activity') || msg.toLowerCase().includes('resolve')) { setAmberError(t('settingsUi.loginDialog.amberNotFound')); } else { setAmberError(msg); } setAmberStep('error'); } return; } // ── Web/PWA path ───────────────────────────────────────────────────────── // This is the proper NIP-55 web flow — NO copy/paste required: // 1. We open Amber via an intent:// URL carrying a callbackUrl. // 2. Amber signs the get_public_key request and redirects the browser to // /amber-callback?event=. // 3. AmberCallbackPage stores the pubkey; the poll below (or the // visibilitychange handler) picks it up and completes login.amber(). // The manual-paste step exists ONLY as a last-resort "having trouble?" // escape hatch the user must explicitly open — it is never forced. const currentPath = window.location.hash.replace(/^#/, '') || '/'; localStorage.setItem('amber_return_path', currentPath); const unsub = subscribeToAmberReturn(() => { checkAmberReturnSilently(); }); window.__amberUnsubRef = unsub; openAmberForLogin(); }; // Silent variant used when the app regains focus: complete login if Amber's // callback delivered the pubkey, otherwise stay on the loading/waiting screen // (do NOT force the paste step — the redirect may still be in flight). const checkAmberReturnSilently = async () => { 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; } // Best-effort clipboard read (some Amber builds copy the key). Never blocks. 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); } } catch { /* clipboard denied — remain on waiting screen */ } }; // Poll for amber_pending_pubkey (written by AmberCallbackPage). This is what // makes the proper NIP-55 redirect flow complete automatically — no paste. // We keep polling on BOTH 'loading' and 'paste' so a late-arriving callback // still auto-completes even if the user opened the manual escape hatch. useEffect(() => { if (amberStep !== 'loading' && amberStep !== 'paste') 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); }, 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: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.connectedAmber') }); setTimeout(() => { onLogin(); onClose(); }, 800); } catch (e: unknown) { setAmberError((e as Error)?.message ?? t('settingsUi.loginDialog.loginFailed')); 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(t('settingsUi.loginDialog.pubkeyError')); }; /** * NIP-46 nostrconnect: show a QR, wait for a phone signer to pair, then hand * the derived bunker URI to the existing (well-tested) bunker login path. */ const handleNostrConnect = () => { setConnectError(''); setConnectState('waiting'); const session = startNostrConnect(nostr, { appImage: 'https://skate-spots.org/images/icon-1024.png', timeoutMs: 180_000, }); connectRef.current = session; setConnectSession(session); session.connected .then(async ({ signerPubkey }) => { const uri = nostrConnectBunkerUri(signerPubkey, session.relays, session.secret); await login.bunker(uri); toast({ title: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.pairedSigner') }); onLogin(); onClose(); }) .catch((e: unknown) => { setConnectState('error'); setConnectError((e as Error)?.message ?? t('settingsUi.loginDialog.connectFailed')); }); }; const cancelNostrConnect = () => { connectRef.current?.cancel(); connectRef.current = null; setConnectSession(null); setConnectState('idle'); setConnectError(''); }; /** NIP-49: decrypt a password-protected key, then log in with it. */ const handleNcryptsecLogin = () => { setNsecError(''); try { const nsec = decryptNcryptsec(ncryptsecInput, ncryptsecPassword); login.nsec(nsec); toast({ title: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.openedEncrypted') }); onLogin(); onClose(); } catch (e: unknown) { setNsecError((e as Error)?.message ?? t('settingsUi.loginDialog.openKeyFailed')); } }; // Reset when dialog closes useEffect(() => { if (!isOpen) { // Clean up any pending Amber visibilitychange listener window.__amberUnsubRef?.(); window.__amberUnsubRef = undefined; // Cancel any in-flight signer pairing (it holds a relay subscription). connectRef.current?.cancel(); connectRef.current = null; setConnectSession(null); setConnectState('idle'); setConnectError(''); setCopiedUri(false); setAmberStep('idle'); setAmberError(''); setShowManualPaste(false); setPubkeyInput(''); setPubkeyError(''); setBunkerUri(''); setBunkerError(''); setNsecInput(''); setNsecError(''); setNcryptsecInput(''); setNcryptsecPassword(''); setNsecMode('raw'); setIsLoading(false); } }, [isOpen]); // Cancel a pairing if the component unmounts mid-wait. useEffect(() => () => { connectRef.current?.cancel(); }, []); const handleBunkerLogin = async () => { const uri = bunkerUri.trim(); if (!uri.startsWith('bunker://')) { setBunkerError(t('settingsUi.loginDialog.bunkerPrefix')); return; } setIsLoading(true); setBunkerError(''); try { await login.bunker(uri); toast({ title: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.connectedBunker') }); onLogin(); onClose(); } catch (e: unknown) { setBunkerError(t('settingsUi.loginDialog.bunkerFailed', { error: (e as Error)?.message ?? t('settingsUi.loginDialog.checkBunkerUri') })); } finally { setIsLoading(false); } }; const handleNsecLogin = () => { const val = nsecInput.trim(); if (!val.startsWith('nsec1')) { setNsecError(t('settingsUi.loginDialog.nsecPrefix')); return; } try { login.nsec(val); toast({ title: t('settingsUi.loginDialog.loggedInTitle') }); onLogin(); onClose(); } catch (e: unknown) { setNsecError(t('settingsUi.loginDialog.invalidNsec', { error: (e as Error)?.message ?? t('settingsUi.loginDialog.checkKey') })); } }; const handleExtensionLogin = async () => { try { await login.extension(); onLogin(); onClose(); } catch { toast({ title: t('settingsUi.loginDialog.noExtensionTitle'), description: t('settingsUi.loginDialog.noExtensionDesc'), variant: 'destructive' }); } }; return ( {t('settingsUi.loginDialog.welcomeTitle')} {t('settingsUi.loginDialog.welcomeSubtitleBrowse')}
{/* Sign up — a compact row, not a second hero: the dialog should have ONE dominant action per state, and logging in is the main path. */}

{t('settingsUi.loginDialog.newHere')}

{t('settingsUi.loginDialog.createFree')}

{t('settingsUi.loginDialog.orLogIn')}
setLoginTab(v as typeof loginTab)} className="w-full"> {/* Method cards — plain-language labels; protocol names move to a tiny hint so newcomers aren't met with "NIP-46" as the main word. */}
{([ { id: 'extension', Icon: Shield, label: t('settingsUi.loginDialog.extensionCard'), hint: 'NIP-07' }, { id: isAndroid ? 'amber' : 'bunker', Icon: isAndroid ? Smartphone : Cloud, label: t('settingsUi.loginDialog.signerCard'), hint: isAndroid ? 'NIP-55' : 'NIP-46' }, { id: 'connect', Icon: QrCode, label: t('settingsUi.loginDialog.signInWithPhone'), hint: 'NIP-46' }, ] as const).map(({ id, Icon, label, hint }) => { const active = loginTab === id; return ( ); })}
{/* ── AMBER TAB ── */} {/* Success */} {amberStep === 'done' && (

{t('settingsUi.loginDialog.loggedIn')}

)} {/* Idle — the primary, one-tap NIP-55 flow */} {amberStep === 'idle' && (

{t('settingsUi.loginDialog.loginWithAmber')}

{t('settingsUi.loginDialog.amberIdleDesc')}

{t('settingsUi.loginDialog.noAmberGetIt')}
)} {/* Waiting — the redirect flow is in progress. Auto-completes via the poll. Only reveals the manual escape hatch on explicit tap. */} {amberStep === 'loading' && !showManualPaste && (

{t('settingsUi.loginDialog.waitingAmberTitle')}

{t('settingsUi.loginDialog.waitingAmberDesc')}

)} {/* Manual paste — LAST-RESORT escape hatch only. Reached when the user taps "Having trouble?" or an explicit error occurs. */} {(amberStep === 'paste' || amberStep === 'error' || (amberStep === 'loading' && showManualPaste)) && (

{t('settingsUi.loginDialog.enterPublicKey')}

{t('settingsUi.loginDialog.enterPublicKeyDescBefore')}{' '} {t('settingsUi.loginDialog.copyPublicKey')}, {t('settingsUi.loginDialog.enterPublicKeyDescAfter')}

{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}

}
)}
{/* ── NOSTR CONNECT TAB ── */} {/* Phone pairing — the canonical "log in on desktop with the key already on your phone" flow. Client-generated URI; the signer scans and pairs over a relay. */}

{t('settingsUi.loginDialog.signInWithPhone')}

{t('settingsUi.loginDialog.signInWithPhoneDesc')}

{connectState === 'idle' && ( )} {connectState === 'waiting' && connectSession && (

{t('settingsUi.loginDialog.waitingSigner')}

)} {connectState === 'error' && (

{connectError}

)}
{/* ── SIGNER (BUNKER) TAB ── */}

{t('settingsUi.loginDialog.signerCard')}

{ 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}

} {/* Instructions collapse: the default state is just input + Connect. */}
{t('settingsUi.loginDialog.whereBunker')}

Amber: {t('settingsUi.loginDialog.amberInstructions')}

nsec.app: {t('settingsUi.loginDialog.nsecAppInstructions')}

{/* ── NSEC TAB ── */}

{t('settingsUi.loginDialog.secretKeyTitle')}

{nsecMode === 'raw' ? t('settingsUi.loginDialog.secretKeyRawDesc') : t('settingsUi.loginDialog.secretKeyEncDesc')}

{/* Raw nsec vs NIP-49 encrypted key */}
{(['raw', 'encrypted'] as const).map((m) => ( ))}
{nsecMode === 'raw' ? ( <>

{t('settingsUi.loginDialog.nsecWarning')}

{ 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}

}
) : ( <>

{t('settingsUi.loginDialog.encSaferDesc')}

{ setNcryptsecInput(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} /> { setNcryptsecPassword(e.target.value); setNsecError(''); }} onKeyDown={e => { if (e.key === 'Enter' && ncryptsecInput.trim() && ncryptsecPassword) handleNcryptsecLogin(); }} className='bg-black/60 border-yellow-500/30 text-white placeholder:text-gray-600' autoComplete='off' autoCorrect='off' spellCheck={false} /> {nsecError &&

{nsecError}

}
)}
{/* ── EXTENSION TAB ── */}

{t('settingsUi.loginDialog.browserExtensionTitle')}

{t('settingsUi.loginDialog.browserExtensionDesc')}

{/* Quiet fallback for advanced users; not a third hero action. */}
); }; export default LoginDialog;