SkateSpots/src/components/auth/LoginDialog.tsx
shakespeare.diy de5de7abec Fix getPublicKey: JSON parse JSObject result + restore AMBER_PACKAGE arg
- JSObject from Capacitor needs JSON.parse(JSON.stringify()) to be
  accessible as plain JS object with property access
- Restore AMBER_PACKAGE as argument (null was causing broken URI)
- Remove debug toast from LoginDialog

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-09 14:28:58 +07:00

416 lines
20 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 } from 'react';
// Extend window with a cleanup ref for Amber visibilitychange listener
declare global {
interface Window { __amberUnsubRef?: () => void; }
}
import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, KeyRound } 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 { openAmberForLogin, subscribeToAmberReturn } from '@/lib/amber';
import { getAmberPublicKeyViaPlugin } from '@/lib/capacitorAmberSigner';
interface LoginDialogProps {
isOpen: boolean;
onClose: () => void;
onLogin: () => void;
onSignup?: () => void;
}
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 NIP-55 state
const [amberStep, setAmberStep] = useState<'idle' | 'loading' | 'paste' | 'done' | 'error'>('idle');
const [amberError, setAmberError] = useState('');
const login = useLoginActions();
const { toast } = useToast();
const isAndroid = /Android/i.test(navigator.userAgent);
// Detect if running inside a Capacitor native app
const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.();
// Step 1: open Amber
// - On Capacitor (ZapStore APK): use the native plugin — direct Promise, no redirects needed
// - On web/PWA: use intent:// URL with callbackUrl so Amber redirects back
const handleAmberLogin = async () => {
setAmberStep('loading');
setAmberError('');
if (isCapacitor) {
// ── Native path (ZapStore/Capacitor APK) ────────────────────────────────
// Attempt login directly — skip isExternalSignerInstalled() check because
// Android 11+ package visibility rules can make it return false even when
// Amber IS installed (requires <queries> in AndroidManifest.xml).
try {
// This opens Amber natively, user approves, pubkey comes back as a Promise
const pubkey = await getAmberPublicKeyViaPlugin();
completeAmberLogin(pubkey);
} catch (e: unknown) {
const msg = (e as Error)?.message ?? 'Amber login failed';
// Distinguish: user cancelled vs Amber not installed vs other error
if (msg.toLowerCase().includes('cancel') || msg.toLowerCase().includes('denied')) {
setAmberError('Login cancelled. Tap "Open Amber" to try again.');
} else if (msg.toLowerCase().includes('not found') || msg.toLowerCase().includes('no activity') || msg.toLowerCase().includes('resolve')) {
setAmberError('Amber not found. Make sure Amber is installed from the ZapStore.');
} else {
setAmberError(msg);
}
setAmberStep('error');
}
return;
}
// ── Web/PWA path ─────────────────────────────────────────────────────────
// Store return path before navigating away
const currentPath = window.location.hash.replace(/^#/, '') || '/';
localStorage.setItem('amber_return_path', currentPath);
// Listen for app regaining focus (after Amber redirect)
const unsub = subscribeToAmberReturn(() => {
checkAmberPendingPubkey();
});
window.__amberUnsubRef = unsub;
openAmberForLogin();
};
// Check if AmberCallbackPage already wrote a pubkey to localStorage
// (happens when Amber opens the callback in a new browser tab on Android)
const checkAmberPendingPubkey = () => {
const storedPubkey = localStorage.getItem('amber_pending_pubkey');
if (storedPubkey && /^[0-9a-f]{64}$/i.test(storedPubkey)) {
localStorage.removeItem('amber_pending_pubkey');
completeAmberLogin(storedPubkey.toLowerCase());
return;
}
// Also try clipboard as fallback
tryClipboardForPubkey();
};
// Try reading the pubkey from clipboard (Amber may have copied it there)
const tryClipboardForPubkey = async () => {
try {
const text = (await navigator.clipboard.readText()).trim();
if (/^[0-9a-f]{64}$/i.test(text)) {
completeAmberLogin(text.toLowerCase());
return;
}
if (text.startsWith('npub1')) {
const { nip19 } = await import('nostr-tools');
const decoded = nip19.decode(text);
if (decoded.type === 'npub') {
completeAmberLogin(decoded.data as string);
return;
}
}
} catch { /* clipboard denied — fall through to manual paste */ }
// Couldn't auto-detect — show the manual paste UI
setAmberStep('paste');
};
// Poll for amber_pending_pubkey (written by AmberCallbackPage in a separate tab)
useEffect(() => {
if (amberStep !== 'loading') return;
const interval = setInterval(() => {
const storedPubkey = localStorage.getItem('amber_pending_pubkey');
if (storedPubkey && /^[0-9a-f]{64}$/i.test(storedPubkey)) {
localStorage.removeItem('amber_pending_pubkey');
clearInterval(interval);
completeAmberLogin(storedPubkey.toLowerCase());
}
}, 500);
// Stop polling after 3 minutes
const timeout = setTimeout(() => {
clearInterval(interval);
if (amberStep === 'loading') setAmberStep('paste');
}, 180_000);
return () => { clearInterval(interval); clearTimeout(timeout); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [amberStep]);
const completeAmberLogin = (pubkey: string) => {
try {
login.amber(pubkey);
setAmberStep('done');
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
setTimeout(() => { onLogin(); onClose(); }, 800);
} catch (e: unknown) {
setAmberError((e as Error)?.message ?? 'Login failed');
setAmberStep('error');
}
};
const [pubkeyInput, setPubkeyInput] = useState('');
const [pubkeyError, setPubkeyError] = useState('');
const handlePubkeySubmit = async () => {
const val = pubkeyInput.trim();
// Accept hex pubkey or npub
if (/^[0-9a-f]{64}$/i.test(val)) {
completeAmberLogin(val.toLowerCase());
return;
}
if (val.startsWith('npub1')) {
try {
const { nip19 } = await import('nostr-tools');
const decoded = nip19.decode(val);
if (decoded.type === 'npub') {
completeAmberLogin(decoded.data as string);
return;
}
} catch { /* fall through */ }
}
setPubkeyError('Paste your hex pubkey (64 chars) or npub from Amber');
};
// Reset when dialog closes
useEffect(() => {
if (!isOpen) {
// Clean up any pending Amber visibilitychange listener
window.__amberUnsubRef?.();
window.__amberUnsubRef = undefined;
setAmberStep('idle');
setAmberError('');
setPubkeyInput('');
setPubkeyError('');
setBunkerUri('');
setBunkerError('');
setNsecInput('');
setNsecError('');
setIsLoading(false);
}
}, [isOpen]);
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' }); }
};
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'>
{/* Success */}
{amberStep === 'done' && (
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-green-500/30 space-y-3'>
<Check className='w-10 h-10 mx-auto text-green-500' />
<p className='text-sm text-green-400 font-bold'>Logged in!</p>
<Loader2 className="w-4 h-4 mx-auto animate-spin text-green-500" />
</div>
)}
{/* Idle / loading — open Amber button */}
{(amberStep === 'idle' || amberStep === 'loading') && (
<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">
{amberStep === 'loading' ? <Loader2 className='w-7 h-7 text-[#ccff00] animate-spin' /> : <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'>Opens Amber → approve → come back here.</p>
</div>
<Button
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
onClick={handleAmberLogin}
disabled={amberStep === 'loading'}
>
<Smartphone className="w-4 h-4 mr-2" />Open Amber
</Button>
</div>
)}
{/* Paste pubkey — after returning from Amber */}
{(amberStep === 'paste' || amberStep === 'error') && (
<div className='p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
<div className='text-center space-y-1'>
<Smartphone className='w-8 h-8 mx-auto text-[#ccff00]' />
<p className='text-sm text-white font-bold'>Paste your pubkey</p>
<p className='text-xs text-gray-400'>
In Amber: tap your profile → <strong className='text-white'>Copy public key</strong>. Then paste below.
</p>
</div>
{amberStep === 'error' && amberError && (
<p className='text-xs text-red-400 bg-red-500/10 rounded-lg p-2 text-center'>{amberError}</p>
)}
<div className='space-y-2'>
<Input
placeholder='npub1... or hex pubkey'
value={pubkeyInput}
onChange={e => { setPubkeyInput(e.target.value); setPubkeyError(''); }}
className='bg-black/60 border-[#ccff00]/30 text-white placeholder:text-gray-600 text-xs font-mono'
autoComplete='off' autoCorrect='off' spellCheck={false}
/>
{pubkeyError && <p className="text-xs text-red-400">{pubkeyError}</p>}
</div>
<Button
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
disabled={!pubkeyInput.trim()}
onClick={handlePubkeySubmit}
>
<Check className='w-4 h-4 mr-2' />Connect
</Button>
<Button variant='ghost' className='w-full text-xs text-gray-600 h-auto py-1'
onClick={() => { setAmberStep('idle'); setPubkeyInput(''); setPubkeyError(''); setAmberError(''); }}>
← Back
</Button>
</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;