SkateSpots/src/components/auth/LoginDialog.tsx
shakespeare.diy bd149fdb2c Fix Amber nostrconnect flow: 20s timeout, error state, stale closure fix, better relays, debug logs
- 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>
2026-07-03 11:48:03 +07:00

572 lines
25 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 [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;