SkateSpots/src/components/auth/LoginDialog.tsx

716 lines
36 KiB
TypeScript

// 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<LoginDialogProps> = ({ 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<NostrConnectHandle | null>(null);
const [connectState, setConnectState] = useState<'idle' | 'waiting' | 'error'>('idle');
const [connectError, setConnectError] = useState('');
const [copiedUri, setCopiedUri] = useState(false);
const connectRef = useRef<NostrConnectHandle | null>(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 <queries> 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=<pubkey>.
// 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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className={cn("z-[2200] flex w-[calc(100%-1rem)] flex-col sm:max-w-md max-h-[calc(100dvh-2rem)] p-0 overflow-hidden rounded-2xl bg-black border-2 border-[#ff0040]/50")}>
<DialogHeader className={cn('px-5 pt-5 pb-3')}>
<DialogTitle className="text-center font-black uppercase tracking-wide text-lg">
{t('settingsUi.loginDialog.welcomeTitle')}
</DialogTitle>
<DialogDescription className="text-center text-xs text-gray-400">
{t('settingsUi.loginDialog.welcomeSubtitleBrowse')}
</DialogDescription>
</DialogHeader>
<div className='min-h-0 flex-1 overflow-y-auto px-5 pb-5 pt-1 space-y-4'>
{/* 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. */}
<div className='flex items-center gap-3 rounded-xl border border-[#ccff00]/40 bg-[#ccff00]/5 p-3'>
<div className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#ccff00]/15'>
<Sparkles className='h-4 w-4 text-[#ccff00]' />
</div>
<div className='min-w-0 flex-1'>
<p className='text-sm font-bold text-[#ccff00]'>{t('settingsUi.loginDialog.newHere')}</p>
<p className='text-[11px] leading-snug text-gray-400'>{t('settingsUi.loginDialog.createFree')}</p>
</div>
<Button
size='sm'
onClick={() => { onClose(); onSignup?.(); }}
className='h-9 shrink-0 gap-1.5 bg-[#ccff00] px-3 text-xs font-bold text-black hover:bg-[#ccff00]/90'
>
<UserPlus className='h-3.5 w-3.5' />{t('settingsUi.loginDialog.createAccount')}
</Button>
</div>
<div className='relative flex items-center'>
<div className='flex-1 border-t border-gray-800' />
<span className='px-3 text-[11px] text-gray-500'>{t('settingsUi.loginDialog.orLogIn')}</span>
<div className='flex-1 border-t border-gray-800' />
</div>
<Tabs value={loginTab} onValueChange={(v) => 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. */}
<div className="mb-3 grid grid-cols-3 gap-2">
{([
{ 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 (
<button
key={id}
type="button"
onClick={() => setLoginTab(id)}
className={cn(
'flex min-h-0 flex-col items-center gap-1 rounded-xl border p-2.5 text-center transition-colors',
active
? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-[#ccff00]'
: 'border-white/10 bg-black/40 text-gray-300 hover:border-white/25 hover:text-white',
)}
>
<Icon className="h-5 w-5" />
<span className="text-[11px] font-bold leading-tight">{label}</span>
<span className="text-[9px] uppercase tracking-widest text-gray-500">{hint}</span>
</button>
);
})}
</div>
{/* ── AMBER TAB ── */}
<TabsContent value='amber' className='space-y-3'>
{/* Success */}
{amberStep === 'done' && (
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-green-500/30 space-y-3'>
<Check className='w-10 h-10 mx-auto text-green-500' />
<p className='text-sm text-green-400 font-bold'>{t('settingsUi.loginDialog.loggedIn')}</p>
<Loader2 className="w-4 h-4 mx-auto animate-spin text-green-500" />
</div>
)}
{/* Idle — the primary, one-tap NIP-55 flow */}
{amberStep === 'idle' && (
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
<div className="w-14 h-14 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto">
<Smartphone className='w-7 h-7 text-[#ccff00]' />
</div>
<div>
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.loginWithAmber')}</p>
<p className='text-xs text-gray-400 mt-1'>
{t('settingsUi.loginDialog.amberIdleDesc')}
</p>
</div>
<Button
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
onClick={handleAmberLogin}
>
<Smartphone className="w-4 h-4 mr-2" />{t('settingsUi.loginDialog.continueWithAmber')}
</Button>
<a
href="https://github.com/greenart7c3/Amber"
target="_blank"
rel="noopener noreferrer"
className="block text-[11px] text-gray-600 hover:text-[#ccff00]"
>
{t('settingsUi.loginDialog.noAmberGetIt')}
</a>
</div>
)}
{/* Waiting — the redirect flow is in progress. Auto-completes via
the poll. Only reveals the manual escape hatch on explicit tap. */}
{amberStep === 'loading' && !showManualPaste && (
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
<div className="w-14 h-14 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto">
<Loader2 className='w-7 h-7 text-[#ccff00] animate-spin' />
</div>
<div>
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.waitingAmberTitle')}</p>
<p className='text-xs text-gray-400 mt-1'>
{t('settingsUi.loginDialog.waitingAmberDesc')}
</p>
</div>
<Button
variant='outline'
className='w-full rounded-full py-2.5 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10 font-bold'
onClick={handleAmberLogin}
>
<Smartphone className="w-4 h-4 mr-2" />{t('settingsUi.loginDialog.openAmberAgain')}
</Button>
<button
className='w-full text-[11px] text-gray-600 hover:text-gray-400 py-1'
onClick={() => setShowManualPaste(true)}
>
{t('settingsUi.loginDialog.havingTrouble')}
</button>
</div>
)}
{/* 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)) && (
<div className='p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
<div className='text-center space-y-1'>
<Smartphone className='w-8 h-8 mx-auto text-[#ccff00]' />
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.enterPublicKey')}</p>
<p className='text-xs text-gray-400'>
{t('settingsUi.loginDialog.enterPublicKeyDescBefore')}{' '}
<strong className='text-white'>{t('settingsUi.loginDialog.copyPublicKey')}</strong>, {t('settingsUi.loginDialog.enterPublicKeyDescAfter')}
</p>
</div>
{amberStep === 'error' && amberError && (
<p className='text-xs text-red-400 bg-red-500/10 rounded-lg p-2 text-center'>{amberError}</p>
)}
<div className='space-y-2'>
<Input
placeholder='npub1... or hex pubkey'
value={pubkeyInput}
onChange={e => { 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 && <p className="text-xs text-red-400">{pubkeyError}</p>}
</div>
<Button
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
disabled={!pubkeyInput.trim()}
onClick={handlePubkeySubmit}
>
<Check className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.connect')}
</Button>
<Button variant='ghost' className='w-full text-xs text-gray-600 h-auto py-1'
onClick={() => { setAmberStep('idle'); setShowManualPaste(false); setPubkeyInput(''); setPubkeyError(''); setAmberError(''); }}>
{t('settingsUi.loginDialog.back')}
</Button>
</div>
)}
</TabsContent>
{/* ── NOSTR CONNECT TAB ── */}
<TabsContent value='connect' className='space-y-3'>
{/* 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. */}
<div className='p-5 rounded-xl bg-black/50 border-2 border-blue-500/30 space-y-4'>
<div className='text-center'>
<QrCode className='w-10 h-10 mx-auto mb-2 text-blue-400' />
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.signInWithPhone')}</p>
<p className='text-xs text-gray-400 mt-1'>
{t('settingsUi.loginDialog.signInWithPhoneDesc')}
</p>
</div>
{connectState === 'idle' && (
<Button
className='w-full rounded-full py-3 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase'
onClick={handleNostrConnect}
>
<QrCode className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.showPairingCode')}
</Button>
)}
{connectState === 'waiting' && connectSession && (
<div className='space-y-3'>
<div className='mx-auto w-fit rounded-xl bg-white p-3'>
<QRCodeCanvas value={connectSession.uri} size={196} level='M' />
</div>
<p className='flex items-center justify-center gap-2 text-center text-xs text-gray-400'>
<Loader2 className='w-3.5 h-3.5 animate-spin' /> {t('settingsUi.loginDialog.waitingSigner')}
</p>
<div className='flex gap-2'>
<Button
variant='outline'
className='flex-1 rounded-full text-xs border-blue-500/40 text-blue-300 hover:bg-blue-500/10'
onClick={() => {
navigator.clipboard.writeText(connectSession.uri).then(() => {
setCopiedUri(true);
setTimeout(() => setCopiedUri(false), 1500);
}).catch(() => {});
}}
>
{copiedUri
? <><Check className='w-3.5 h-3.5 mr-1' />{t('common.copied')}</>
: <><Copy className='w-3.5 h-3.5 mr-1' />{t('settingsUi.loginDialog.copyLink')}</>}
</Button>
<Button
variant='outline'
className='flex-1 rounded-full text-xs border-white/15 text-gray-400 hover:bg-white/5'
onClick={cancelNostrConnect}
>
{t('common.cancel')}
</Button>
</div>
</div>
)}
{connectState === 'error' && (
<div className='space-y-2'>
<p className='text-xs text-red-400 bg-red-500/10 rounded-lg p-2 text-center'>{connectError}</p>
<Button
className='w-full rounded-full py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase'
onClick={handleNostrConnect}
>
{t('common.tryAgain')}
</Button>
</div>
)}
</div>
</TabsContent>
{/* ── SIGNER (BUNKER) TAB ── */}
<TabsContent value='bunker' className='space-y-3'>
<div className='space-y-3 rounded-xl border border-blue-500/30 bg-black/50 p-4'>
<div className='flex items-center gap-2'>
<Cloud className='h-4 w-4 text-blue-400' />
<p className='text-sm font-bold text-white'>{t('settingsUi.loginDialog.signerCard')}</p>
</div>
<Input placeholder='bunker://...' value={bunkerUri} onChange={e => { 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 && <p className="text-xs text-red-400">{bunkerError}</p>}
<Button className='w-full rounded-full py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase' disabled={isLoading || !bunkerUri.trim()} onClick={handleBunkerLogin}>
{isLoading ? <Loader2 className='w-4 h-4 animate-spin mr-2' /> : <Cloud className='w-4 h-4 mr-2' />}{t('settingsUi.loginDialog.connect')}
</Button>
{/* Instructions collapse: the default state is just input + Connect. */}
<details>
<summary className='cursor-pointer text-[11px] text-gray-500 hover:text-gray-300'>
{t('settingsUi.loginDialog.whereBunker')}
</summary>
<div className='mt-2 space-y-1 rounded-lg border border-gray-800 bg-black/40 p-3 text-[11px] text-gray-400'>
<p><strong className='text-blue-300'>Amber:</strong> {t('settingsUi.loginDialog.amberInstructions')}</p>
<p><strong className='text-blue-300'>nsec.app:</strong> {t('settingsUi.loginDialog.nsecAppInstructions')}</p>
<div className='flex gap-2 pt-1'>
<a href="https://nsec.app" target="_blank" rel="noopener noreferrer" className="rounded-full bg-blue-500/20 px-2.5 py-0.5 text-blue-400 hover:bg-blue-500/30">nsec.app →</a>
<a href="https://nostrum.social" target="_blank" rel="noopener noreferrer" className="rounded-full bg-blue-500/20 px-2.5 py-0.5 text-blue-400 hover:bg-blue-500/30">Nostrum →</a>
</div>
</div>
</details>
</div>
</TabsContent>
{/* ── NSEC TAB ── */}
<TabsContent value='nsec' className='space-y-3'>
<div className='p-5 rounded-xl bg-black/50 border-2 border-yellow-500/30 space-y-4'>
<div className='text-center'>
<KeyRound className='w-10 h-10 mx-auto mb-2 text-yellow-400' />
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.secretKeyTitle')}</p>
<p className='text-xs text-gray-400 mt-1'>
{nsecMode === 'raw'
? t('settingsUi.loginDialog.secretKeyRawDesc')
: t('settingsUi.loginDialog.secretKeyEncDesc')}
</p>
</div>
{/* Raw nsec vs NIP-49 encrypted key */}
<div className='flex gap-1 rounded-full bg-black/50 border border-white/10 p-1'>
{(['raw', 'encrypted'] as const).map((m) => (
<button
key={m}
type='button'
onClick={() => { setNsecMode(m); setNsecError(''); }}
className={cn(
'min-h-0 flex-1 rounded-full px-2 py-1 text-[11px] font-bold uppercase tracking-wide transition-colors',
nsecMode === m ? 'bg-yellow-500 text-black' : 'text-gray-400 hover:text-white',
)}
>
{m === 'raw' ? t('settingsUi.loginDialog.rawNsec') : t('settingsUi.loginDialog.encryptedNip49')}
</button>
))}
</div>
{nsecMode === 'raw' ? (
<>
<div className='p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30'>
<p className='text-xs text-yellow-300'>{t('settingsUi.loginDialog.nsecWarning')}</p>
</div>
<div className='space-y-2'>
<Input type="password" placeholder='nsec1...' value={nsecInput} onChange={e => { 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 && <p className="text-xs text-red-400">{nsecError}</p>}
</div>
<Button className='w-full rounded-full py-3 bg-yellow-500 hover:bg-yellow-600 text-black font-bold uppercase' disabled={!nsecInput.trim()} onClick={handleNsecLogin}>
<KeyRound className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.login')}
</Button>
</>
) : (
<>
<div className='p-3 rounded-lg bg-green-500/10 border border-green-500/30'>
<p className='text-xs text-green-300'>
{t('settingsUi.loginDialog.encSaferDesc')}
</p>
</div>
<div className='space-y-2'>
<Input
placeholder='ncryptsec1...'
value={ncryptsecInput}
onChange={e => { 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}
/>
<Input
type='password'
placeholder={t('settingsUi.loginDialog.password')}
value={ncryptsecPassword}
onChange={e => { 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 && <p className="text-xs text-red-400">{nsecError}</p>}
</div>
<Button
className='w-full rounded-full py-3 bg-yellow-500 hover:bg-yellow-600 text-black font-bold uppercase'
disabled={!ncryptsecInput.trim() || !ncryptsecPassword}
onClick={handleNcryptsecLogin}
>
<Unlock className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.openEncryptedKey')}
</Button>
</>
)}
</div>
</TabsContent>
{/* ── EXTENSION TAB ── */}
<TabsContent value='extension' className='space-y-3'>
<div className='space-y-3 rounded-xl border border-[#ff0040]/30 bg-black/50 p-4'>
<div className='flex items-center gap-2'>
<Shield className='h-4 w-4 text-[#ff0040]' />
<p className='text-sm font-bold text-white'>{t('settingsUi.loginDialog.browserExtensionTitle')}</p>
</div>
<p className='text-xs text-gray-400'>{t('settingsUi.loginDialog.browserExtensionDesc')}</p>
<Button className='w-full rounded-full py-2.5 bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold uppercase' onClick={handleExtensionLogin}>
{t('settingsUi.loginDialog.loginWithExtension')}
</Button>
</div>
</TabsContent>
</Tabs>
{/* Quiet fallback for advanced users; not a third hero action. */}
<div className='pt-1 text-center'>
<button
type="button"
onClick={() => setLoginTab('nsec')}
className={cn(
'text-[11px] underline underline-offset-2',
loginTab === 'nsec' ? 'text-yellow-300' : 'text-gray-500 hover:text-gray-300',
)}
>
{t('settingsUi.loginDialog.secretKeyLink')}
</button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default LoginDialog;