- Replace wss://relay.primal.net with wss://nos.lol (more reliable for NIP-46) - Shorten timeout from 10 minutes to 20 seconds - Fix stale closure bug: track amberStep in amberStepRef so timeout can correctly reset UI - Add 'error' step with red UI and 'Try Again' button instead of endless spinner - Extend since filter from 5s to 10s in the past - Add console logs for URI, relays, incoming events, decryption result, secret matching Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
572 lines
25 KiB
TypeScript
572 lines
25 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, 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 [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 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;
|
||
}, []);
|
||
|
||
// 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]);
|
||
|
||
// 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 for Amber response...');
|
||
|
||
try {
|
||
const decoded = nip19.decode(pending.clientNsec);
|
||
if (decoded.type !== 'nsec') return;
|
||
const clientSk = decoded.data;
|
||
|
||
const since = Math.floor(pending.timestamp / 1000) - 30;
|
||
|
||
// Re-subscribe with a fresh pool each time app comes to foreground
|
||
const freshPool = new SimplePool();
|
||
let found = false;
|
||
|
||
await new Promise<void>((resolve) => {
|
||
const timeout = setTimeout(() => { resolve(); }, 6000);
|
||
|
||
const sub = freshPool.subscribeMany(
|
||
NOSTRCONNECT_RELAYS,
|
||
[{ kinds: [24133], '#p': [pending.clientPubkey], since }],
|
||
{
|
||
onevent: async (event) => {
|
||
if (found) 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 !== 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 */ }
|
||
},
|
||
oneose: () => {
|
||
// All caught up — wait a bit more then give up
|
||
setTimeout(() => { sub.close(); freshPool.close(NOSTRCONNECT_RELAYS); resolve(); }, 2000);
|
||
},
|
||
}
|
||
);
|
||
});
|
||
|
||
if (!found) {
|
||
setAmberStatus('Not received yet. Did you approve in Amber? Tap retry or come back after approving.');
|
||
}
|
||
} catch { /* ignore */ }
|
||
};
|
||
|
||
document.addEventListener('visibilitychange', handleVisibility);
|
||
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
||
}, [amberStep, completeLogin]);
|
||
|
||
// Reset when dialog closes
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
abortRef.current?.abort();
|
||
abortRef.current = null;
|
||
cleanup();
|
||
amberStepRef.current = 'idle';
|
||
setAmberStep('idle');
|
||
setAmberStatus('');
|
||
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('Waiting for Amber to approve...');
|
||
|
||
// 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;
|
||
|
||
// Timeout after 20 seconds — show error, let user retry
|
||
setTimeout(() => {
|
||
if (!controller.signal.aborted) {
|
||
console.warn('[Amber] Timed out after 20s — 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?');
|
||
}
|
||
}
|
||
}, 20 * 1000);
|
||
}, [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>
|
||
</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>
|
||
|
||
{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;
|