- 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>
416 lines
20 KiB
TypeScript
416 lines
20 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 } 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;
|