SkateSpots/src/components/auth/LoginDialog.tsx
shakespeare.diy 03ea569a2a Amber login: poll relay every 3s after returning from Amber, add elapsed timer + Check Now button
- Replace one-shot visibility handler with pollRelay() called immediately on foreground + every 3s
- Add elapsed time counter (M:SS) so user can see the app is still trying
- Add 'Check Now' button for manual relay poll without generating a new URI
- Poll interval auto-clears on success, timeout, or cancel

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-03 12:34:20 +07:00

621 lines
28 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, useCallback } from 'react';
import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, Copy, RefreshCw, KeyRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import { NLogin } from '@nostrify/react/login';
import { useNostrLogin } from '@nostrify/react/login';
import { NConnectSigner, NSecSigner, NRelay1 } from '@nostrify/nostrify';
import { SimplePool, generateSecretKey, getPublicKey, nip19, nip04 } from 'nostr-tools';
interface LoginDialogProps {
isOpen: boolean;
onClose: () => void;
onLogin: () => void;
onSignup?: () => void;
}
const NOSTRCONNECT_RELAYS = [
'wss://relay.nsec.app',
'wss://relay.damus.io',
'wss://nos.lol',
];
// Key for storing pending nostrconnect session across app restarts
const PENDING_NC_KEY = 'skatespots_pending_nc';
interface PendingNostrConnect {
clientNsec: string;
clientPubkey: string;
secret: string;
relays: string[];
timestamp: number;
}
function buildNostrConnectURI(opts: {
clientPubkey: string;
relays: string[];
secret: string;
name?: string;
url?: string;
perms?: string;
}): string {
const params = new URLSearchParams();
for (const r of opts.relays) params.append('relay', r);
params.set('secret', opts.secret);
if (opts.name) params.set('name', opts.name);
if (opts.url) params.set('url', opts.url);
if (opts.perms) params.set('perms', opts.perms);
return `nostrconnect://${opts.clientPubkey}?${params.toString()}`;
}
function randomSecret(): string {
const arr = new Uint8Array(12);
crypto.getRandomValues(arr);
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
}
function savePendingNC(data: PendingNostrConnect) {
try { localStorage.setItem(PENDING_NC_KEY, JSON.stringify(data)); } catch { /* ignore */ }
}
function loadPendingNC(): PendingNostrConnect | null {
try {
const s = localStorage.getItem(PENDING_NC_KEY);
if (!s) return null;
const d = JSON.parse(s) as PendingNostrConnect;
// Expire after 10 minutes
if (Date.now() - d.timestamp > 10 * 60 * 1000) {
localStorage.removeItem(PENDING_NC_KEY);
return null;
}
return d;
} catch { return null; }
}
function clearPendingNC() {
try { localStorage.removeItem(PENDING_NC_KEY); } catch { /* ignore */ }
}
export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onSignup }) => {
const [isLoading, setIsLoading] = useState(false);
const [bunkerUri, setBunkerUri] = useState('');
const [bunkerError, setBunkerError] = useState('');
const [nsecInput, setNsecInput] = useState('');
const [nsecError, setNsecError] = useState('');
const [amberStep, setAmberStep] = useState<'idle' | 'connecting' | 'waiting' | 'error' | 'success'>('idle');
const [amberStatus, setAmberStatus] = useState('');
const [amberElapsed, setAmberElapsed] = useState(0);
const [nostrConnectUri, setNostrConnectUri] = useState('');
const abortRef = useRef<AbortController | null>(null);
const poolRef = useRef<SimplePool | null>(null);
const subRef = useRef<{ close: () => void } | null>(null);
const pendingRef = useRef<PendingNostrConnect | null>(null);
const amberStepRef = useRef<'idle' | 'connecting' | 'waiting' | 'error' | 'success'>('idle');
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const elapsedIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const login = useLoginActions();
const { addLogin, setLogin } = useNostrLogin();
const { toast } = useToast();
const isAndroid = /Android/i.test(navigator.userAgent);
const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.();
const cleanup = useCallback(() => {
try { subRef.current?.close(); } catch { /* ignore */ }
try { poolRef.current?.close(NOSTRCONNECT_RELAYS); } catch { /* ignore */ }
subRef.current = null;
poolRef.current = null;
if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; }
if (elapsedIntervalRef.current) { clearInterval(elapsedIntervalRef.current); elapsedIntervalRef.current = null; }
}, []);
// Complete login once we have signerPubkey from any source
const completeLogin = useCallback(async (signerPubkey: string, clientSk: Uint8Array, clientNsec: string, relays: string[]) => {
setAmberStatus('Getting your pubkey...');
let userPubkey = signerPubkey;
// Try to get the real user pubkey via NConnectSigner
try {
const clientSigner = new NSecSigner(clientSk);
const relay = new NRelay1(relays[0]);
const signer = new NConnectSigner({
pubkey: signerPubkey,
signer: clientSigner,
relay,
timeout: 15000,
});
userPubkey = await signer.getPublicKey();
try { relay.close(); } catch { /* ignore */ }
} catch {
// Amber signs as the user's key directly, so signerPubkey IS the userPubkey
userPubkey = signerPubkey;
}
const nlogin = new NLogin('bunker', userPubkey, {
bunkerPubkey: signerPubkey,
clientNsec,
relays,
});
addLogin(nlogin);
setLogin(nlogin.id);
clearPendingNC();
cleanup();
amberStepRef.current = 'success';
setAmberStep('success');
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
setTimeout(() => { onLogin(); onClose(); }, 800);
}, [addLogin, setLogin, cleanup, onLogin, onClose, toast]);
// Poll relay once — returns true if Amber's response was found and login completed
const pollRelay = useCallback(async (): Promise<boolean> => {
const pending = pendingRef.current || loadPendingNC();
if (!pending || amberStepRef.current !== 'waiting') return false;
console.log('[Amber] Polling relay for response...');
setAmberStatus('Checking relay for Amber response...');
try {
const decoded = nip19.decode(pending.clientNsec);
if (decoded.type !== 'nsec') return false;
const clientSk = decoded.data;
const since = Math.floor(pending.timestamp / 1000) - 30;
const freshPool = new SimplePool();
let found = false;
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => { freshPool.close(NOSTRCONNECT_RELAYS); resolve(); }, 6000);
const sub = freshPool.subscribeMany(
NOSTRCONNECT_RELAYS,
[{ kinds: [24133], '#p': [pending.clientPubkey], since }],
{
onevent: async (event) => {
if (found) return;
console.log('[Amber] Poll received event from:', event.pubkey);
try {
const decrypted = await nip04.decrypt(clientSk, event.pubkey, event.content);
const parsed = JSON.parse(decrypted) as { id: string; result: string; error?: string };
if (parsed.error && parsed.error !== '') return;
if (parsed.result !== pending.secret && parsed.result !== 'ack') return;
found = true;
clearTimeout(timeout);
sub.close();
freshPool.close(NOSTRCONNECT_RELAYS);
pendingRef.current = null;
abortRef.current?.abort();
await completeLogin(event.pubkey, clientSk, pending.clientNsec, pending.relays);
resolve();
} catch { /* wrong encryption key or malformed event */ }
},
oneose: () => {
setTimeout(() => { sub.close(); freshPool.close(NOSTRCONNECT_RELAYS); resolve(); }, 1500);
},
}
);
});
if (!found && amberStepRef.current === 'waiting') {
setAmberStatus('Nothing yet — still waiting for Amber...');
}
return found;
} catch { return false; }
}, [completeLogin]);
// When app returns from Amber: immediately poll, then keep polling every 3s
useEffect(() => {
const handleVisibility = async () => {
if (document.visibilityState !== 'visible') return;
if (amberStepRef.current !== 'waiting') return;
console.log('[Amber] App foregrounded — starting poll loop');
// Poll immediately on return
const found = await pollRelay();
if (found) return;
// Then poll every 3 seconds until success or timeout/cancel clears the interval
if (!pollIntervalRef.current) {
pollIntervalRef.current = setInterval(async () => {
if (amberStepRef.current !== 'waiting') {
clearInterval(pollIntervalRef.current!);
pollIntervalRef.current = null;
return;
}
const done = await pollRelay();
if (done) {
clearInterval(pollIntervalRef.current!);
pollIntervalRef.current = null;
}
}, 3000);
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, [pollRelay]);
// Reset when dialog closes
useEffect(() => {
if (!isOpen) {
abortRef.current?.abort();
abortRef.current = null;
cleanup();
amberStepRef.current = 'idle';
setAmberStep('idle');
setAmberStatus('');
setAmberElapsed(0);
setNostrConnectUri('');
setBunkerUri('');
setBunkerError('');
setNsecInput('');
setNsecError('');
setIsLoading(false);
}
}, [isOpen, cleanup]);
const startAmberConnect = useCallback(async () => {
abortRef.current?.abort();
cleanup();
const controller = new AbortController();
abortRef.current = controller;
const clientSk = generateSecretKey();
const clientPubkey = getPublicKey(clientSk);
const clientNsec = nip19.nsecEncode(clientSk);
const secret = randomSecret();
const uri = buildNostrConnectURI({
clientPubkey,
relays: NOSTRCONNECT_RELAYS,
secret,
name: 'SkateSpots',
url: 'https://skate-spots.org',
perms: 'get_public_key,sign_event,nip44_encrypt,nip44_decrypt',
});
const pending: PendingNostrConnect = {
clientNsec,
clientPubkey,
secret,
relays: NOSTRCONNECT_RELAYS,
timestamp: Date.now(),
};
pendingRef.current = pending;
savePendingNC(pending);
setNostrConnectUri(uri);
amberStepRef.current = 'connecting';
setAmberStep('connecting');
setAmberStatus('Opening Amber...');
console.log('[Amber] Generated nostrconnect URI:', uri);
console.log('[Amber] Relays:', NOSTRCONNECT_RELAYS);
console.log('[Amber] Client pubkey:', clientPubkey);
try { window.open(uri, '_blank'); } catch { /* user copies manually */ }
amberStepRef.current = 'waiting';
setAmberStep('waiting');
setAmberStatus('Approve in Amber, then come back here.');
setAmberElapsed(0);
if (elapsedIntervalRef.current) clearInterval(elapsedIntervalRef.current);
elapsedIntervalRef.current = setInterval(() => {
setAmberElapsed(s => s + 1);
}, 1000);
// Live relay subscription (works on web/PWA; may be backgrounded in Capacitor)
const pool = new SimplePool();
poolRef.current = pool;
const since = Math.floor(Date.now() / 1000) - 10;
console.log('[Amber] Subscribing to relays since:', since);
const sub = pool.subscribeMany(
NOSTRCONNECT_RELAYS,
[{ kinds: [24133], '#p': [clientPubkey], since }],
{
onevent: async (event) => {
if (controller.signal.aborted) return;
console.log('[Amber] Received event from:', event.pubkey);
try {
const decrypted = await nip04.decrypt(clientSk, event.pubkey, event.content);
const parsed = JSON.parse(decrypted) as { id: string; result: string; error?: string };
console.log('[Amber] Decrypted response:', parsed);
if (parsed.error && parsed.error !== '') {
console.warn('[Amber] Error in response:', parsed.error);
return;
}
if (parsed.result !== secret && parsed.result !== 'ack') {
console.warn('[Amber] Secret mismatch, ignoring');
return;
}
console.log('[Amber] Valid response — completing login');
controller.abort();
pendingRef.current = null;
await completeLogin(event.pubkey, clientSk, clientNsec, NOSTRCONNECT_RELAYS);
} catch (e) {
console.warn('[Amber] Failed to decrypt event:', e);
}
},
}
);
subRef.current = sub;
// On Capacitor (native app) give 3 minutes — the visibility change handler does the real work
// after the user returns from Amber, so the live subscription just needs to stay alive.
// On web/PWA the live subscription fires instantly, so 3 min is still fine as a safety net.
const TIMEOUT_MS = isCapacitor ? 3 * 60 * 1000 : 3 * 60 * 1000;
setTimeout(() => {
if (!controller.signal.aborted) {
console.warn('[Amber] Timed out — no response received');
controller.abort();
cleanup();
if (amberStepRef.current === 'waiting') {
amberStepRef.current = 'error';
setAmberStep('error');
setAmberStatus('Amber did not respond in time. Did you approve the connection?');
}
}
}, TIMEOUT_MS);
}, [cleanup, completeLogin, toast]);
const handleBunkerLogin = async () => {
const uri = bunkerUri.trim();
if (!uri.startsWith('bunker://')) { setBunkerError('Must start with bunker://'); return; }
setIsLoading(true);
setBunkerError('');
try {
await login.bunker(uri);
toast({ title: '🛹 Logged in!', description: 'Connected via bunker!' });
onLogin(); onClose();
} catch (e: unknown) {
setBunkerError(`Failed: ${(e as Error)?.message ?? 'Check your bunker URI'}`);
} finally { setIsLoading(false); }
};
const handleNsecLogin = () => {
const val = nsecInput.trim();
if (!val.startsWith('nsec1')) { setNsecError('Must start with nsec1'); return; }
try {
login.nsec(val);
toast({ title: '🛹 Logged in!' });
onLogin(); onClose();
} catch (e: unknown) {
setNsecError(`Invalid nsec: ${(e as Error)?.message ?? 'Check your key'}`);
}
};
const handleExtensionLogin = async () => {
try { await login.extension(); onLogin(); onClose(); }
catch { toast({ title: 'No extension found', description: 'Install Alby or another NIP-07 extension.', variant: 'destructive' }); }
};
const copyUri = () => {
navigator.clipboard.writeText(nostrConnectUri).catch(() => {});
toast({ title: 'Copied!', description: 'Open Amber → Scan / Paste link → paste it' });
};
const openAmberWithUri = () => {
if (nostrConnectUri) { try { window.open(nostrConnectUri, '_blank'); } catch { copyUri(); } }
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className={cn("w-[calc(100%-1rem)] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl bg-black border-2 border-[#ff0040]/50")}>
<DialogHeader className={cn('px-4 sm:px-6 pt-4 sm:pt-6 pb-1')}>
<DialogDescription className="text-center text-sm text-gray-400">
Sign up or log in to continue
</DialogDescription>
</DialogHeader>
<div className='px-4 sm:px-6 pt-2 pb-6 space-y-4 overflow-y-auto'>
{/* Sign Up */}
<div className='relative p-5 rounded-2xl bg-black border-2 border-[#ccff00]/50 overflow-hidden'>
<div className="absolute inset-0 bg-[#ccff00]/5 blur-xl" />
<div className='relative z-10 text-center space-y-3'>
<div className='flex justify-center items-center gap-2'>
<Sparkles className='w-5 h-5 text-[#ccff00]' />
<span className='font-bold text-[#ccff00] uppercase tracking-wide'>New here?</span>
</div>
<p className='text-xs text-gray-300'>Create a free account. No email required.</p>
<Button onClick={() => { onClose(); onSignup?.(); }} className='w-full rounded-full py-3 font-bold bg-[#ccff00] text-black hover:bg-[#ccff00]/90 uppercase'>
<UserPlus className='w-4 h-4 mr-2' />Create Account
</Button>
</div>
</div>
<div className='relative flex items-center'>
<div className='flex-1 border-t border-gray-800' />
<span className='px-3 text-xs text-gray-500'>Or log in</span>
<div className='flex-1 border-t border-gray-800' />
</div>
<Tabs defaultValue={isAndroid ? 'amber' : 'bunker'} className="w-full">
<TabsList className="grid w-full grid-cols-4 bg-black/50 border border-[#ff0040]/30 rounded-lg mb-4 h-auto p-1">
<TabsTrigger value="amber" className="flex items-center gap-1 text-xs py-2 px-1">
<Smartphone className="w-3 h-3" /><span>Amber</span>
</TabsTrigger>
<TabsTrigger value="bunker" className="flex items-center gap-1 text-xs py-2 px-1">
<Cloud className="w-3 h-3" /><span>Bunker</span>
</TabsTrigger>
<TabsTrigger value="nsec" className="flex items-center gap-1 text-xs py-2 px-1">
<KeyRound className="w-3 h-3" /><span>nsec</span>
</TabsTrigger>
<TabsTrigger value="extension" className="flex items-center gap-1 text-xs py-2 px-1">
<Shield className="w-3 h-3" /><span>Ext</span>
</TabsTrigger>
</TabsList>
{/* ── AMBER TAB ── */}
<TabsContent value='amber' className='space-y-3'>
{(amberStep === 'idle' || amberStep === 'connecting') && (
<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'>Login with Amber</p>
<p className='text-xs text-gray-400 mt-1'>NIP-46 remote signing. Tap, approve in Amber, done.</p>
</div>
<Button
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
onClick={startAmberConnect}
disabled={amberStep === 'connecting'}
>
{amberStep === 'connecting'
? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Opening...</>
: <><Smartphone className="w-4 h-4 mr-2" />Connect with Amber</>
}
</Button>
</div>
)}
{(amberStep === 'waiting' || amberStep === 'error') && (
<div className={`p-5 rounded-xl bg-black/50 border-2 space-y-4 ${amberStep === 'error' ? 'border-red-500/40' : 'border-[#ccff00]/40'}`}>
<div className='text-center space-y-2'>
{amberStep === 'error'
? <p className='text-sm text-red-400 font-bold'>⏱ Timed out</p>
: <Loader2 className='w-8 h-8 text-[#ccff00] animate-spin mx-auto' />
}
<p className='text-sm text-white font-bold'>
{amberStep === 'error' ? 'No response from Amber' : 'Waiting for Amber...'}
</p>
<p className='text-xs text-gray-400'>{amberStatus}</p>
{amberStep === 'waiting' && (
<p className='text-xs text-gray-600 tabular-nums'>
{Math.floor(amberElapsed / 60)}:{String(amberElapsed % 60).padStart(2, '0')} elapsed
</p>
)}
</div>
<div className='text-xs text-gray-300 bg-black/40 rounded-lg p-3 space-y-1 border border-gray-800'>
<p className='font-bold text-white mb-2'>In Amber:</p>
<p>1. Tap <strong className='text-[#ccff00]'>Scan / Paste link</strong></p>
<p>2. Paste the connection string below</p>
<p>3. Tap <strong className='text-[#ccff00]'>Approve</strong></p>
<p>4. Come back here</p>
</div>
<div className='flex gap-2'>
<Button variant="outline" className='flex-1 rounded-full border-[#ccff00]/30 text-[#ccff00] hover:bg-[#ccff00]/10 text-xs h-auto py-2' onClick={openAmberWithUri}>
<Smartphone className='w-3 h-3 mr-1' />Open Amber
</Button>
<Button variant="outline" className='flex-1 rounded-full border-gray-700 text-gray-300 hover:bg-gray-800 text-xs h-auto py-2' onClick={copyUri}>
<Copy className='w-3 h-3 mr-1' />Copy URI
</Button>
</div>
{amberStep === 'waiting' && (
<Button variant="outline" className='w-full rounded-full border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10 text-xs h-auto py-2' onClick={pollRelay}>
<RefreshCw className='w-3 h-3 mr-1' />Check Now
</Button>
)}
{isCapacitor && (
<p className='text-xs text-center text-gray-500'>
After approving in Amber, come back to this app. It will detect the connection automatically.
</p>
)}
<div className='flex gap-2'>
<Button variant="ghost" className='flex-1 text-xs text-gray-500 h-auto py-2' onClick={() => { abortRef.current?.abort(); clearPendingNC(); cleanup(); amberStepRef.current = 'idle'; setAmberStep('idle'); }}>
Cancel
</Button>
<Button
variant="ghost"
className={`flex-1 text-xs h-auto py-2 ${amberStep === 'error' ? 'text-red-400 font-bold' : 'text-[#ccff00]'}`}
onClick={startAmberConnect}
>
<RefreshCw className='w-3 h-3 mr-1' />{amberStep === 'error' ? 'Try Again' : 'Retry'}
</Button>
</div>
</div>
)}
{amberStep === 'success' && (
<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'>Logged in!</p>
<Loader2 className="w-4 h-4 mx-auto animate-spin text-green-500" />
</div>
)}
</TabsContent>
{/* ── BUNKER TAB ── */}
<TabsContent value='bunker' className='space-y-3'>
<div className='p-5 rounded-xl bg-black/50 border-2 border-blue-500/30 space-y-4'>
<div className='text-center'>
<Cloud className='w-10 h-10 mx-auto mb-2 text-blue-400' />
<p className='text-sm text-white font-bold'>Bunker (NIP-46)</p>
<p className='text-xs text-gray-400 mt-1'>Paste your <code className='text-blue-300'>bunker://</code> URI.</p>
</div>
<div className='text-xs text-gray-300 bg-black/40 rounded-lg p-3 space-y-1 border border-gray-800'>
<p><strong className='text-blue-300'>Amber:</strong> Settings → NIP-46 Bunker → Create → Copy</p>
<p><strong className='text-blue-300'>nsec.app:</strong> Connect app → Copy bunker URI</p>
</div>
<div className='space-y-2'>
<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>}
</div>
<Button className='w-full rounded-full py-3 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' />}Connect
</Button>
<div className='flex gap-2 justify-center'>
<a href="https://nsec.app" target="_blank" rel="noopener noreferrer" className="text-xs px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full hover:bg-blue-500/30">nsec.app →</a>
<a href="https://nostrum.social" target="_blank" rel="noopener noreferrer" className="text-xs px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full hover:bg-blue-500/30">Nostrum →</a>
</div>
</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'>Secret Key (nsec)</p>
<p className='text-xs text-gray-400 mt-1'>Paste your Nostr secret key directly.</p>
</div>
<div className='p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30'>
<p className='text-xs text-yellow-300'>⚠️ Only use this on a trusted device. Never share your nsec.</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' />Login
</Button>
</div>
</TabsContent>
{/* ── EXTENSION TAB ── */}
<TabsContent value='extension' className='space-y-3'>
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ff0040]/30 space-y-4'>
<Shield className='w-10 h-10 mx-auto text-[#ff0040]' />
<div>
<p className='text-sm text-white font-bold'>Browser Extension (NIP-07)</p>
<p className='text-xs text-gray-400 mt-1'>Best for desktop. Works with Alby, nos2x, Flamingo.</p>
</div>
<Button className='w-full rounded-full py-3 bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold uppercase' onClick={handleExtensionLogin}>
Login with Extension
</Button>
</div>
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
);
};
export default LoginDialog;