SkateSpots/src/components/auth/LoginDialog.tsx
shakespeare.diy 0c87fa5827 Fix Amber login not completing on GrapheneOS new-tab callback
Root cause: on GrapheneOS, Amber opens /amber-callback in a BRAND NEW TAB.
login.amber() was being called in that new tab's React instance, which has
no effect on the original tab where the user is waiting.

Fix:
- AmberCallbackPage now writes pubkey to localStorage('amber_pending_pubkey')
  instead of calling login.amber() — a cross-tab signal
- LoginDialog polls for 'amber_pending_pubkey' every 400ms and calls
  login.amber() itself in the correct React context
- Manual "I approved" button also reads the same key
- Removed reliance on getAmberLogin() which checked the wrong localStorage key

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-17 22:46:39 +07:00

514 lines
23 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 } from 'react';
import { Shield, AlertTriangle, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; // used for bunker URI input
import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import { subscribeToAmberReturn } from '@/lib/amber';
interface LoginDialogProps {
isOpen: boolean;
onClose: () => void;
onLogin: () => void;
onSignup?: () => void;
}
const validateBunkerUri = (uri: string) => {
return uri.startsWith('bunker://');
};
const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onSignup }) => {
const [isLoading, setIsLoading] = useState(false);
const [bunkerUri, setBunkerUri] = useState('');
const [errors, setErrors] = useState<{
bunker?: string;
extension?: string;
amber?: string;
}>({});
const [amberStep, setAmberStep] = useState<'initial' | 'waiting' | 'success'>('initial');
const unsubAmberRef = useRef<(() => void) | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const login = useLoginActions();
const { toast } = useToast();
// Reset all state when dialog opens/closes
useEffect(() => {
if (isOpen) {
setIsLoading(false);
setBunkerUri('');
setErrors({});
setAmberStep('initial');
}
if (!isOpen) {
// Clean up listeners when dialog closes
if (unsubAmberRef.current) { unsubAmberRef.current(); unsubAmberRef.current = null; }
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
}
}, [isOpen]);
// Clean up on unmount
useEffect(() => {
return () => {
if (unsubAmberRef.current) unsubAmberRef.current();
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
const handleExtensionLogin = async () => {
setIsLoading(true);
setErrors(prev => ({ ...prev, extension: undefined }));
try {
if (!('nostr' in window)) {
throw new Error('Nostr extension not found. Please install a NIP-07 extension.');
}
await login.extension();
onLogin();
onClose();
} catch (e: unknown) {
const error = e as Error;
console.error('Bunker login failed:', error);
console.error('Nsec login failed:', error);
console.error('Extension login failed:', error);
setErrors(prev => ({
...prev,
extension: error instanceof Error ? error.message : 'Extension login failed'
}));
} finally {
setIsLoading(false);
}
};
const handleAmberLogin = () => {
setAmberStep('waiting');
setErrors(prev => ({ ...prev, amber: undefined }));
// Use localStorage (not sessionStorage) — survives navigation on Android browsers
localStorage.setItem('amber_return_path', window.location.pathname);
// Stamp the time so we know this is a fresh attempt
localStorage.setItem('amber_login_attempt_ts', String(Date.now()));
// Poll localStorage every 400ms for 'amber_pending_pubkey'.
// AmberCallbackPage writes this key when Amber approves — even if it runs
// in a separate tab (GrapheneOS opens callback in new tab). This tab picks
// it up, calls login.amber() here where React state is alive, and completes.
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(() => {
const pendingPubkey = localStorage.getItem('amber_pending_pubkey');
if (pendingPubkey) {
// Consume it immediately so we don't process twice
localStorage.removeItem('amber_pending_pubkey');
clearInterval(pollRef.current!);
pollRef.current = null;
try {
login.amber(pendingPubkey);
setAmberStep('success');
toast({ title: '🛹 Logged in', description: 'Welcome back via Amber!' });
setTimeout(() => { onLogin(); onClose(); }, 600);
} catch (err) {
console.error('[Amber] login.amber failed in poll:', err);
setErrors(prev => ({ ...prev, amber: 'Login failed. Please try again.' }));
setAmberStep('initial');
}
}
}, 400);
// Also try visibilitychange for browsers that keep the tab alive
unsubAmberRef.current = subscribeToAmberReturn(() => {
setAmberStep(prev => prev === 'waiting' ? 'initial' : prev);
unsubAmberRef.current = null;
});
// Open Amber — it will call /amber-callback?event=<pubkey> on approve
const callbackUrl = `${window.location.origin}/amber-callback?event=`;
const amberUrl = `nostrsigner:?compressionType=none&returnType=signature&type=get_public_key&callbackUrl=${encodeURIComponent(callbackUrl)}`;
window.location.href = amberUrl;
};
const handleBunkerLogin = async () => {
if (!bunkerUri.trim()) {
setErrors(prev => ({ ...prev, bunker: 'Please enter a bunker URI' }));
return;
}
if (!validateBunkerUri(bunkerUri)) {
setErrors(prev => ({ ...prev, bunker: 'Invalid bunker URI format. Must start with bunker://' }));
return;
}
setIsLoading(true);
setErrors(prev => ({ ...prev, bunker: undefined }));
try {
await login.bunker(bunkerUri);
onLogin();
onClose();
// Clear the URI from memory
setBunkerUri('');
} catch {
setErrors(prev => ({
...prev,
bunker: 'Failed to connect to bunker. Please check the URI.'
}));
} finally {
setIsLoading(false);
}
};
const handleSignupClick = () => {
onClose();
if (onSignup) {
onSignup();
}
};
// Detect device type for better UX
const isAndroid = /Android/i.test(navigator.userAgent);
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
const isMobile = isAndroid || isIOS;
const hasExtension = typeof window !== 'undefined' && 'nostr' in window;
// Detect best default tab based on device and browser capabilities
const getDefaultTab = () => {
// Android - Amber is the best option
if (isAndroid) return 'amber';
// iOS - Bunker is recommended (no Amber support)
if (isIOS) return 'bunker';
// Desktop with extension - use extension
if (hasExtension) return 'extension';
// Desktop without extension - show extension tab with install prompt
return 'extension';
};
const defaultTab = getDefaultTab();
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 relative')}>
<DialogDescription className="text-center text-sm">
Sign up or log in to continue
</DialogDescription>
</DialogHeader>
<div className='px-4 sm:px-6 pt-2 pb-4 space-y-4 overflow-y-auto flex-1'>
{/* Prominent Sign Up Section */}
<div className='relative p-6 rounded-2xl bg-black border-2 border-[#ccff00]/50 overflow-hidden'>
{/* Neon glow effect */}
<div className="absolute inset-0 bg-[#ccff00]/5 blur-xl"></div>
<div className='relative z-10 text-center space-y-3'>
<div className='flex justify-center items-center gap-2 mb-2'>
<Sparkles className='w-5 h-5 text-[#ccff00]' />
<span className='font-bold text-[#ccff00] uppercase tracking-wide' style={{
textShadow: '0 0 10px #ccff00'
}}>
New here?
</span>
</div>
<p className='text-sm text-gray-300'>
Create a free account in seconds. No email required.
</p>
<Button
onClick={handleSignupClick}
className='w-full rounded-full py-4 text-base font-bold bg-[#ccff00] text-black hover:bg-[#ccff00]/90 transform transition-all duration-200 hover:scale-105 shadow-[0_0_20px_rgba(204,255,0,0.5)] border-0 uppercase tracking-wide'
>
<UserPlus className='w-4 h-4 mr-2' />
<span>Create Account</span>
</Button>
</div>
</div>
{/* Divider */}
<div className='relative'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-t border-gray-300 dark:border-gray-600'></div>
</div>
<div className='relative flex justify-center text-sm'>
<span className='px-3 bg-background text-muted-foreground'>
<span>Or log in</span>
</span>
</div>
</div>
{/* Login Methods */}
<Tabs defaultValue={defaultTab} 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="extension" className="flex flex-col sm:flex-row items-center gap-0.5 sm:gap-1 text-[10px] sm:text-sm py-1.5 sm:py-2 px-1 sm:px-3">
<Shield className="w-4 h-4" />
<span className="hidden sm:inline">Extension</span>
<span className="sm:hidden">Ext</span>
</TabsTrigger>
<TabsTrigger value="amber" className="flex flex-col sm:flex-row items-center gap-0.5 sm:gap-1 text-[10px] sm:text-sm py-1.5 sm:py-2 px-1 sm:px-3">
<Smartphone className="w-4 h-4" />
<span>Amber</span>
</TabsTrigger>
<TabsTrigger value="bunker" className="flex flex-col sm:flex-row items-center gap-0.5 sm:gap-1 text-[10px] sm:text-sm py-1.5 sm:py-2 px-1 sm:px-3">
<Cloud className="w-4 h-4" />
<span className="hidden sm:inline">Bunker</span>
<span className="sm:hidden">Bnk</span>
</TabsTrigger>
</TabsList>
<TabsContent value='extension' className='space-y-3'>
{errors.extension && (
<Alert variant="destructive" className="bg-red-950/50 border-red-500/50">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">
<strong>No extension detected.</strong> Try using the <strong>Create Account</strong> button above{isAndroid ? ', or use Amber on Android' : isIOS ? ', or use Bunker for iOS' : ''}.
</AlertDescription>
</Alert>
)}
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-[#ff0040]/30'>
<Shield className='w-12 h-12 mx-auto mb-3 text-[#ff0040]' />
<p className='text-sm text-gray-300 mb-2 font-bold'>
{isMobile ? 'Browser Extension' : 'One-Click Login (Desktop)'}
</p>
<p className='text-xs text-gray-400 mb-4'>
{isMobile
? 'Works with mobile browsers that have Nostr extensions (e.g., Kiwi Browser + Alby).'
: 'Best for desktop browsers with Alby, nos2x, or similar extensions installed.'
}
</p>
<div className="flex justify-center">
<Button
className='w-full rounded-full py-4 bg-[#ff0040] hover:bg-[#ff0040]/90 border-2 border-[#ff0040] shadow-[0_0_20px_rgba(255,0,64,0.5)] font-bold uppercase'
onClick={handleExtensionLogin}
disabled={isLoading}
>
{isLoading ? 'Logging in...' : 'Login with Extension'}
</Button>
</div>
</div>
</TabsContent>
<TabsContent value='amber' className='space-y-3'>
{isIOS && (
<Alert className="bg-yellow-950/50 border-yellow-500/50">
<AlertTriangle className="h-4 w-4 text-yellow-500" />
<AlertDescription className="text-sm text-yellow-200">
Amber is Android-only. For iOS, use <strong>Bunker</strong> (nsec.app or Nostrum).
</AlertDescription>
</Alert>
)}
{/* Initial state — single tap */}
{amberStep === 'initial' && (
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
<div className="w-16 h-16 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto">
<Smartphone className='w-8 h-8 text-[#ccff00]' />
</div>
<div>
<p className='text-sm text-white font-bold mb-1'>
Login with Amber
</p>
<p className='text-xs text-gray-400'>
Tap below — Amber opens, you approve once, and you're automatically logged in. No copy-paste needed.
</p>
</div>
<Button
className='w-full rounded-full py-4 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 border-2 border-[#ccff00] shadow-[0_0_20px_rgba(204,255,0,0.5)] font-bold uppercase text-base h-auto'
onClick={handleAmberLogin}
disabled={isLoading || isIOS}
>
<Smartphone className="w-4 h-4 mr-2" />
{isIOS ? 'Android only' : 'Open Amber'}
</Button>
<p className='text-xs text-gray-500'>
Don't have Amber?{' '}
<a
href="https://github.com/greenart7c3/Amber/releases"
target="_blank"
rel="noopener noreferrer"
className="text-[#ccff00] hover:underline"
>
Download on GitHub
</a>
{' '}or{' '}
<a
href="https://f-droid.org/packages/com.greenart7c3.nostrsigner/"
target="_blank"
rel="noopener noreferrer"
className="text-[#ccff00] hover:underline"
>
F-Droid
</a>
</p>
</div>
)}
{/* Waiting — user is in Amber */}
{amberStep === 'waiting' && (
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-[#ccff00]/40 space-y-4'>
<div className="w-16 h-16 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto">
<Loader2 className='w-8 h-8 text-[#ccff00] animate-spin' />
</div>
<div>
<p className='text-sm text-white font-bold mb-1'>Waiting for Amber...</p>
<p className='text-xs text-gray-400'>
Approve the request in Amber, then come back here.
</p>
</div>
{/* Animated dots */}
<div className="flex justify-center gap-1.5">
{[0, 1, 2].map(i => (
<span
key={i}
className="w-2 h-2 rounded-full bg-[#ccff00] animate-bounce"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
{/* Fallback for Android browsers that navigate away —
polling handles auto-complete, but this button is a
manual escape hatch if the user lands back here stuck */}
<p className="text-xs text-gray-500">
Already approved in Amber?
</p>
<Button
className="w-full rounded-full bg-[#ccff00]/20 border border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/30 font-bold text-sm h-auto py-3"
onClick={() => {
// Check for pending pubkey written by AmberCallbackPage
const pendingPubkey = localStorage.getItem('amber_pending_pubkey');
if (pendingPubkey) {
localStorage.removeItem('amber_pending_pubkey');
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
try {
login.amber(pendingPubkey);
setAmberStep('success');
toast({ title: '🛹 Logged in', description: 'Welcome back via Amber!' });
setTimeout(() => { onLogin(); onClose(); }, 600);
} catch {
setErrors(prev => ({ ...prev, amber: 'Login failed. Please try again.' }));
setAmberStep('initial');
}
} else {
setErrors(prev => ({ ...prev, amber: 'Not approved yet — go back to Amber and approve first.' }));
}
}}
>
I approved — complete login
</Button>
{errors.amber && (
<p className="text-xs text-red-400">{errors.amber}</p>
)}
<Button
variant="ghost"
className="text-xs text-gray-600 hover:text-gray-400 h-auto py-1"
onClick={() => {
setAmberStep('initial');
setErrors(prev => ({ ...prev, amber: undefined }));
if (unsubAmberRef.current) { unsubAmberRef.current(); unsubAmberRef.current = null; }
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
}}
>
Cancel, try again
</Button>
</div>
)}
{/* Success */}
{amberStep === 'success' && (
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-green-500/30 space-y-3'>
<Check className='w-12 h-12 mx-auto text-green-500' />
<p className='text-sm text-green-400 font-bold'>Logged in!</p>
<Loader2 className="w-5 h-5 mx-auto animate-spin text-green-500" />
</div>
)}
</TabsContent>
<TabsContent value='bunker' className='space-y-4'>
{isIOS && (
<Alert className="bg-blue-950/50 border-blue-500/50">
<Cloud className="h-4 w-4 text-blue-400" />
<AlertDescription className="text-sm text-blue-200">
<strong>Recommended for iOS!</strong> Use nsec.app for the best experience on iPhone/iPad.
</AlertDescription>
</Alert>
)}
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-blue-500/30'>
<Cloud className='w-12 h-12 mx-auto mb-3 text-blue-400' />
<p className='text-sm text-gray-300 mb-2 font-bold'>
Login with Nostr Connect {isIOS && '(Best for iOS)'}
</p>
<p className='text-xs text-gray-400 mb-4'>
{isIOS
? 'Works on any device! Get a bunker:// connection string from nsec.app and paste it below.'
: 'Access your account from any device using a remote signer (bunker). Your keys stay safe on the server.'
}
</p>
<div className='space-y-2'>
<label htmlFor='bunkerUri' className='text-sm font-medium block text-left'>
Bunker Connection String
</label>
<Input
id='bunkerUri'
value={bunkerUri}
onChange={(e) => {
setBunkerUri(e.target.value);
if (errors.bunker) setErrors(prev => ({ ...prev, bunker: undefined }));
}}
className={`rounded-lg ${
errors.bunker ? 'border-red-500' : ''
}`}
placeholder='bunker://...'
autoComplete="off"
/>
{errors.bunker && (
<p className="text-sm text-red-500">{errors.bunker}</p>
)}
</div>
<div className="flex justify-center mt-4">
<Button
className='w-full rounded-full py-4 bg-blue-500 hover:bg-blue-600 border-2 border-blue-500 shadow-[0_0_20px_rgba(59,130,246,0.5)] font-bold uppercase'
onClick={handleBunkerLogin}
disabled={isLoading || !bunkerUri.trim()}
>
{isLoading ? 'Connecting...' : 'Connect to Bunker'}
</Button>
</div>
<div className='mt-4 pt-4 border-t border-gray-700'>
<p className='text-xs text-gray-500 mb-2'>Get a bunker connection from:</p>
<div className="flex flex-wrap 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 transition-colors"
>
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 transition-colors"
>
Nostrum →
</a>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
);
};
export default LoginDialog;