SkateSpots/src/components/auth/LoginDialog.tsx
shakespeare.diy 92f317f5e5 fix: Amber login for GrapheneOS — clipboard + paste flow
GrapheneOS Vanadium browser blocks intent: URLs from web pages as a
security hardening measure. This is why every URL format we tried
caused "malformed request" — Vanadium was blocking the intent before
Amber ever saw it.

New flow that works on ALL Android browsers including GrapheneOS:
1. Tap "Open Amber" → navigates to nostrsigner: URL with no callbackUrl
   (Amber copies pubkey to clipboard when no callbackUrl is given)
2. User approves in Amber, comes back to app
3. App tries navigator.clipboard.readText() automatically on visibilitychange
4. If clipboard permission denied → shows paste input for hex pubkey
5. User pastes pubkey → logged in

No intent: scheme, no callback URL, no redirect loop. Simple and bulletproof.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-08 11:43:59 +07:00

333 lines
17 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';
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';
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' | 'waiting' | 'paste' | 'done'>('idle');
const [pubkeyInput, setPubkeyInput] = useState('');
const [pubkeyError, setPubkeyError] = useState('');
const login = useLoginActions();
const { toast } = useToast();
const isAndroid = /Android/i.test(navigator.userAgent);
// Step 1: open Amber asking it to copy pubkey to clipboard (no callback URL)
const handleAmberLogin = () => {
// nostrsigner: with no callbackUrl → Amber copies pubkey to clipboard
// This works on ALL Android browsers including GrapheneOS Vanadium
window.location.href = 'intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end';
setAmberStep('waiting');
};
// Step 2: user comes back — try clipboard automatically, else show paste input
useEffect(() => {
if (amberStep !== 'waiting') return;
const handleVisible = async () => {
if (document.visibilityState !== 'visible') return;
// Try reading clipboard automatically
try {
const text = await navigator.clipboard.readText();
const clean = text.trim().toLowerCase();
if (/^[0-9a-f]{64}$/.test(clean)) {
completeLogin(clean);
return;
}
} catch { /* clipboard permission denied — fall through to paste UI */ }
// Show paste input
setAmberStep('paste');
};
document.addEventListener('visibilitychange', handleVisible);
return () => document.removeEventListener('visibilitychange', handleVisible);
}, [amberStep]);
const completeLogin = (pubkey: string) => {
try {
login.amber(pubkey);
setAmberStep('done');
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
setTimeout(() => { onLogin(); onClose(); }, 800);
} catch (e) {
console.error('[Amber] login.amber() failed:', e);
setAmberStep('idle');
toast({ title: 'Login failed', description: 'Could not log in with Amber. Try again.', variant: 'destructive' });
}
};
const handlePubkeySubmit = () => {
const clean = pubkeyInput.trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(clean)) {
setPubkeyError('Must be a 64-character hex pubkey');
return;
}
completeLogin(clean);
};
// Reset when dialog closes
useEffect(() => {
if (!isOpen) {
setAmberStep('idle');
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'>
{/* Done */}
{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>
)}
{/* Step 1 idle — show login button */}
{amberStep === 'idle' && (
<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'>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}
>
<Smartphone className="w-4 h-4 mr-2" />Open Amber
</Button>
</div>
)}
{/* Step 2 waiting — app just navigated to Amber */}
{amberStep === 'waiting' && (
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/40 space-y-4'>
<Loader2 className='w-8 h-8 text-[#ccff00] animate-spin mx-auto' />
<div>
<p className='text-sm text-white font-bold'>Approve in Amber</p>
<p className='text-xs text-gray-400 mt-1'>Tap "Get public key" in Amber, then come back here.</p>
</div>
<Button variant="ghost" className='text-xs text-gray-500 h-auto py-2'
onClick={() => setAmberStep('paste')}>
Already approved? Paste pubkey manually
</Button>
<Button variant="ghost" className='text-xs text-gray-600 h-auto py-1'
onClick={() => setAmberStep('idle')}>Cancel</Button>
</div>
)}
{/* Step 3 paste — clipboard read failed or user chose manual */}
{amberStep === 'paste' && (
<div className='p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
<div className='text-center'>
<Smartphone className='w-8 h-8 mx-auto mb-2 text-[#ccff00]' />
<p className='text-sm text-white font-bold'>Paste your pubkey</p>
<p className='text-xs text-gray-400 mt-1'>
In Amber: tap your profile → copy public key (hex). Paste it below.
</p>
</div>
<div className='space-y-2'>
<Input
placeholder='hex pubkey (64 characters)...'
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')}>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;