fix: handle Capacitor WebSocket suspension in NIP-46 Amber login

- Add visibilitychange listener: when app returns to foreground after Amber,
  does a one-shot relay query to catch Amber's connect response
- Store pending nostrconnect session in localStorage so it survives app suspend
- Keep live relay subscription as fallback for web/PWA
- Add nsec login tab as reliable fallback option
- Add proper step states: idle -> connecting -> waiting -> success

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-19 00:38:38 +07:00
parent 789a3d7a60
commit 84d80ac3a7

View file

@ -2,7 +2,7 @@
// 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 } from 'lucide-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";
@ -22,15 +22,23 @@ interface LoginDialogProps {
onSignup?: () => void;
}
// Relays used for NIP-46 nostrconnect:// handshake
// Use relays known to support ephemeral events (kind 24133)
const NOSTRCONNECT_RELAYS = [
'wss://relay.nsec.app',
'wss://relay.damus.io',
'wss://relay.primal.net',
];
/** Build a nostrconnect:// URI per NIP-46 spec */
// 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[];
@ -40,9 +48,7 @@ function buildNostrConnectURI(opts: {
perms?: string;
}): string {
const params = new URLSearchParams();
for (const r of opts.relays) {
params.append('relay', r);
}
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);
@ -50,33 +56,56 @@ function buildNostrConnectURI(opts: {
return `nostrconnect://${opts.clientPubkey}?${params.toString()}`;
}
/** Generate a random short secret string */
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('');
// Amber nostrconnect state
const [amberStep, setAmberStep] = useState<'idle' | 'connecting' | 'success'>('idle');
const [amberStep, setAmberStep] = useState<'idle' | 'connecting' | 'waiting' | 'success'>('idle');
const [amberStatus, setAmberStatus] = useState('');
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 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?.();
// Cleanup pool/subscriptions
const cleanup = useCallback(() => {
try { subRef.current?.close(); } catch { /* ignore */ }
try { poolRef.current?.close(NOSTRCONNECT_RELAYS); } catch { /* ignore */ }
@ -84,7 +113,101 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
poolRef.current = null;
}, []);
// Reset state when dialog closes
// 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();
setAmberStep('success');
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
setTimeout(() => { onLogin(); onClose(); }, 800);
}, [addLogin, setLogin, cleanup, onLogin, onClose, toast]);
// Listen for app visibility change (user returns from Amber) — then poll relay
// This is more reliable in Capacitor than keeping a WebSocket open in background
useEffect(() => {
const handleVisibility = async () => {
if (document.visibilityState !== 'visible') return;
const pending = pendingRef.current || loadPendingNC();
if (!pending || amberStep !== 'waiting') return;
setAmberStatus('Checking relay for Amber response...');
// Quick one-shot relay check when app comes back to foreground
const { nsecDecode } = await import('nostr-tools/nip19').catch(() => ({ nsecDecode: null }));
if (!nsecDecode) return;
try {
const decoded = nip19.decode(pending.clientNsec);
if (decoded.type !== 'nsec') return;
const clientSk = decoded.data;
const pool = new SimplePool();
const since = Math.floor(pending.timestamp / 1000) - 30;
// Fetch recent events — one-shot query
const events = await pool.querySync(
NOSTRCONNECT_RELAYS,
{
kinds: [24133],
'#p': [pending.clientPubkey],
since,
},
{ maxWait: 5000 }
);
pool.close(NOSTRCONNECT_RELAYS);
for (const event of events) {
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 !== '') continue;
if (parsed.result !== pending.secret && parsed.result !== 'ack') continue;
// Found it!
pendingRef.current = null;
abortRef.current?.abort();
await completeLogin(event.pubkey, clientSk, pending.clientNsec, pending.relays);
return;
} catch { /* wrong encryption or format */ }
}
setAmberStatus('No response yet — did you approve in Amber?');
} catch { /* ignore */ }
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, [amberStep, completeLogin]);
// Reset when dialog closes
useEffect(() => {
if (!isOpen) {
abortRef.current?.abort();
@ -95,31 +218,24 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
setNostrConnectUri('');
setBunkerUri('');
setBunkerError('');
setNsecInput('');
setNsecError('');
setIsLoading(false);
}
}, [isOpen, cleanup]);
/**
* Amber login via NIP-46 nostrconnect:// flow.
*
* Uses nostr-tools SimplePool + nip04 for reliable WebSocket subscriptions.
* This is the same approach used by Ditto, Coracle, Nostrudel.
*/
const startAmberConnect = useCallback(async () => {
// Cancel previous attempt
abortRef.current?.abort();
cleanup();
const controller = new AbortController();
abortRef.current = controller;
// Generate ephemeral client keypair
const clientSk = generateSecretKey();
const clientPubkey = getPublicKey(clientSk);
const clientNsec = nip19.nsecEncode(clientSk);
const secret = randomSecret();
// Build the nostrconnect URI
const uri = buildNostrConnectURI({
clientPubkey,
relays: NOSTRCONNECT_RELAYS,
@ -129,181 +245,102 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
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);
setAmberStep('connecting');
setAmberStatus('Opening Amber...');
// Open Amber with the nostrconnect:// URI
try {
window.open(uri, '_blank');
} catch {
// fallback: user copies manually
}
try { window.open(uri, '_blank'); } catch { /* user copies manually */ }
setAmberStep('waiting');
setAmberStatus('Waiting for Amber to approve...');
// Set up SimplePool subscription to listen for Amber's connect response
// Also keep a live relay subscription running (works on web/PWA, may sleep in Capacitor)
const pool = new SimplePool();
poolRef.current = pool;
const since = Math.floor(Date.now() / 1000) - 5;
const connectPromise = new Promise<{ signerPubkey: string; userPubkey: string }>((resolve, reject) => {
// Handle abort
controller.signal.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'));
});
const sub = pool.subscribeMany(
NOSTRCONNECT_RELAYS,
[{ kinds: [24133], '#p': [clientPubkey], since }],
{
onevent: async (event) => {
if (controller.signal.aborted) return;
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 !== secret && parsed.result !== 'ack') return;
let resolved = false;
controller.abort();
pendingRef.current = null;
await completeLogin(event.pubkey, clientSk, clientNsec, NOSTRCONNECT_RELAYS);
} catch { /* ignore */ }
},
}
);
subRef.current = sub;
// Subscribe using nostr-tools SimplePool — proven reliable
const sub = pool.subscribeMany(
NOSTRCONNECT_RELAYS,
[{
kinds: [24133],
'#p': [clientPubkey],
since,
}],
{
onevent: async (event) => {
if (resolved) return;
try {
// Amber encrypts the connect response with NIP-04
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 !== '') {
resolved = true;
reject(new Error(parsed.error));
return;
}
// Accept either our secret or 'ack' as valid connect response
if (parsed.result !== secret && parsed.result !== 'ack') return;
resolved = true;
const signerPubkey = event.pubkey;
setAmberStatus('Connected! Getting your pubkey...');
// Use NConnectSigner to get the actual user pubkey
try {
const clientSigner = new NSecSigner(clientSk);
const relayForSigning = new NRelay1(NOSTRCONNECT_RELAYS[0]);
const signer = new NConnectSigner({
pubkey: signerPubkey,
signer: clientSigner,
relay: relayForSigning,
timeout: 30000,
});
const userPubkey = await signer.getPublicKey();
try { relayForSigning.close(); } catch { /* ignore */ }
resolve({ signerPubkey, userPubkey });
} catch {
// If NConnectSigner fails, use signerPubkey as userPubkey
// (works for Amber since it signs as the user's key directly)
resolve({ signerPubkey, userPubkey: signerPubkey });
}
} catch {
// Decryption failed — not our event or wrong encryption, skip
}
},
oneose: () => {
// EOSE received — we're caught up, still waiting for new events
},
// Timeout after 10 minutes
setTimeout(() => {
if (!controller.signal.aborted) {
controller.abort();
cleanup();
if (amberStep === 'waiting') {
setAmberStep('idle');
toast({ title: 'Timed out', description: 'Amber did not respond. Try again.', variant: 'destructive' });
}
);
subRef.current = sub;
// Timeout after 5 minutes
setTimeout(() => {
if (!resolved) {
reject(new Error('Timeout — Amber did not respond in 5 minutes'));
}
}, 5 * 60 * 1000);
});
try {
const { signerPubkey, userPubkey } = await connectPromise;
cleanup();
// Create NLogin bunker object — same structure NLogin.fromBunker() creates
const nlogin = new NLogin('bunker', userPubkey, {
bunkerPubkey: signerPubkey,
clientNsec,
relays: NOSTRCONNECT_RELAYS,
});
addLogin(nlogin);
setLogin(nlogin.id);
setAmberStep('success');
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
setTimeout(() => { onLogin(); onClose(); }, 800);
} catch (e: unknown) {
cleanup();
if ((e as Error)?.name === 'AbortError') return;
setAmberStep('idle');
setAmberStatus('');
const msg = (e as Error)?.message ?? 'Unknown error';
toast({
title: 'Connection failed',
description: msg.includes('Timeout')
? 'Amber did not respond. Make sure you approved in Amber and try again.'
: `Failed: ${msg}`,
variant: 'destructive',
});
}
}, [addLogin, setLogin, cleanup, onLogin, onClose, toast]);
}
}, 10 * 60 * 1000);
}, [cleanup, completeLogin, amberStep, toast]);
const handleBunkerLogin = async () => {
const uri = bunkerUri.trim();
if (!uri.startsWith('bunker://')) {
setBunkerError('Must start with bunker://');
return;
}
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();
onLogin(); onClose();
} catch (e: unknown) {
setBunkerError(
`Connection failed: ${(e as Error)?.message ?? 'Check your bunker URI and try again.'}`
);
} finally {
setIsLoading(false);
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 a Nostr browser extension like Alby.', variant: 'destructive' });
}
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: 'Paste in Amber → Scan/Paste link' });
toast({ title: 'Copied!', description: 'Open Amber → Scan / Paste link → paste it' });
};
const openAmberWithUri = () => {
if (nostrConnectUri) {
try { window.open(nostrConnectUri, '_blank'); } catch { copyUri(); }
}
if (nostrConnectUri) { try { window.open(nostrConnectUri, '_blank'); } catch { copyUri(); } }
};
return (
@ -325,68 +362,59 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<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 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>
{/* Divider */}
<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>
{/* Login tabs */}
<Tabs defaultValue={isAndroid ? 'amber' : 'bunker'} className="w-full">
<TabsList className="grid w-full grid-cols-3 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-2">
<Smartphone className="w-4 h-4" />
<span>Amber</span>
<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-2">
<Cloud className="w-4 h-4" />
<span>Bunker</span>
<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="extension" className="flex items-center gap-1 text-xs py-2 px-2">
<Shield className="w-4 h-4" />
<span>Ext</span>
<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 === '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 below, approve in Amber, done.
</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'}
>
<Smartphone className="w-4 h-4 mr-2" />
Connect with Amber
{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>
<p className='text-xs text-gray-500'>
Need Amber?{' '}
<a href="https://github.com/greenart7c3/Amber/releases" target="_blank" rel="noopener noreferrer" className="text-[#ccff00] hover:underline">Download →</a>
</p>
</div>
)}
{amberStep === 'connecting' && (
{amberStep === 'waiting' && (
<div className='p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/40 space-y-4'>
<div className='text-center space-y-2'>
<Loader2 className='w-8 h-8 text-[#ccff00] animate-spin mx-auto' />
@ -396,46 +424,33 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<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. Open <strong className='text-white'>Amber</strong></p>
<p>2. Tap <strong className='text-[#ccff00]'>Scan / Paste link</strong></p>
<p>3. Paste the connection string</p>
<p>4. Tap <strong className='text-[#ccff00]'>Approve</strong></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 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 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>
{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 hover:text-gray-300 h-auto py-2'
onClick={() => { abortRef.current?.abort(); cleanup(); setAmberStep('idle'); setAmberStatus(''); }}
>
<Button variant="ghost" className='flex-1 text-xs text-gray-500 h-auto py-2' onClick={() => { abortRef.current?.abort(); clearPendingNC(); cleanup(); setAmberStep('idle'); }}>
Cancel
</Button>
<Button
variant="ghost"
className='flex-1 text-xs text-[#ccff00] hover:text-[#ccff00]/80 h-auto py-2'
onClick={startAmberConnect}
>
<RefreshCw className='w-3 h-3 mr-1' />
Retry
<Button variant="ghost" className='flex-1 text-xs text-[#ccff00] h-auto py-2' onClick={startAmberConnect}>
<RefreshCw className='w-3 h-3 mr-1' />Retry
</Button>
</div>
</div>
@ -455,49 +470,48 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<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 Login (NIP-46)</p>
<p className='text-xs text-gray-400 mt-1'>
Paste your <code className='text-blue-300'>bunker://</code> URI from Amber or nsec.app.
</p>
<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 className='font-bold text-white mb-1'>How to get a bunker URI:</p>
<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}
/>
<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 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='text-center pt-1'>
<div className='flex gap-2 justify-center flex-wrap'>
<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 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'>
@ -506,10 +520,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<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}
>
<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>