fix: rewrite login to use NIP-46 nostrconnect flow (Ditto pattern)
Completely replaces the broken NIP-55 intent approach with NIP-46: - Amber tab: generates nostrconnect:// URI, opens Amber, waits for Amber to connect back via relay using NLogin.fromNostrConnect() (same pattern as Ditto/soapbox-pub) - Bunker tab: paste bunker:// URI from Amber or nsec.app - Extension tab: NIP-07 browser extension (desktop) - useLoginActions: updated to match Ditto's implementation with nostrconnect(), generateNostrConnectParams, generateNostrConnectURI - Clean mobile-first UI — no clipboard hacks, no intent tricks Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
e8bc072787
commit
916f067238
2 changed files with 263 additions and 420 deletions
|
|
@ -1,18 +1,16 @@
|
|||
// 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 React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, Copy, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input'; // used for bunker URI input
|
||||
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 { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { useLoginActions } from '@/hooks/useLoginActions';
|
||||
import { useLoginActions, generateNostrConnectParams, generateNostrConnectURI, type NostrConnectParams, type NostrConnectStatus } from '@/hooks/useLoginActions';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
interface LoginDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -20,472 +18,326 @@ interface LoginDialogProps {
|
|||
onSignup?: () => void;
|
||||
}
|
||||
|
||||
const validateBunkerUri = (uri: string) => {
|
||||
return uri.startsWith('bunker://');
|
||||
};
|
||||
const RELAYS = [
|
||||
'wss://relay.ditto.pub',
|
||||
'wss://relay.nostr.band',
|
||||
];
|
||||
|
||||
const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onSignup }) => {
|
||||
export 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 [bunkerError, setBunkerError] = useState('');
|
||||
|
||||
// Amber nostrconnect state
|
||||
const [amberStep, setAmberStep] = useState<'idle' | 'connecting' | 'success'>('idle');
|
||||
const [amberStatus, setAmberStatus] = useState('');
|
||||
const [nostrConnectUri, setNostrConnectUri] = useState('');
|
||||
const [nostrConnectParams, setNostrConnectParams] = useState<NostrConnectParams | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const login = useLoginActions();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Reset all state when dialog opens/closes
|
||||
const isAndroid = /Android/i.test(navigator.userAgent);
|
||||
|
||||
// Reset 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; }
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setAmberStep('idle');
|
||||
setAmberStatus('');
|
||||
setNostrConnectUri('');
|
||||
setNostrConnectParams(null);
|
||||
setBunkerUri('');
|
||||
setBunkerError('');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (unsubAmberRef.current) unsubAmberRef.current();
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, []);
|
||||
// Generate a fresh nostrconnect:// URI and start waiting
|
||||
const startAmberConnect = useCallback(async () => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const handleExtensionLogin = async () => {
|
||||
setIsLoading(true);
|
||||
setErrors(prev => ({ ...prev, extension: undefined }));
|
||||
const params = generateNostrConnectParams({ relays: RELAYS });
|
||||
const uri = generateNostrConnectURI(params, {
|
||||
name: 'SkateSpots',
|
||||
url: 'https://skate-spots.org',
|
||||
perms: 'sign_event,get_public_key,nip44_encrypt,nip44_decrypt',
|
||||
});
|
||||
|
||||
setNostrConnectParams(params);
|
||||
setNostrConnectUri(uri);
|
||||
setAmberStep('connecting');
|
||||
setAmberStatus('Waiting for Amber to connect...');
|
||||
|
||||
// Open Amber with the nostrconnect:// URI
|
||||
// This works in browser, PWA, and Capacitor — it's a standard URI scheme
|
||||
// that Amber registers as an intent filter
|
||||
window.location.href = uri;
|
||||
|
||||
try {
|
||||
if (!('nostr' in window)) {
|
||||
throw new Error('Nostr extension not found. Please install a NIP-07 extension.');
|
||||
}
|
||||
await login.nostrconnect(
|
||||
params,
|
||||
controller.signal,
|
||||
(status: NostrConnectStatus) => {
|
||||
if (status === 'pending') setAmberStatus('Waiting for Amber...');
|
||||
else if (status === 'connected') setAmberStatus('Connected! Getting your pubkey...');
|
||||
else if (status === 'authenticated') setAmberStatus('Authenticated!');
|
||||
},
|
||||
);
|
||||
setAmberStep('success');
|
||||
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
|
||||
setTimeout(() => { onLogin(); onClose(); }, 800);
|
||||
} catch (e: unknown) {
|
||||
if ((e as Error)?.name === 'AbortError') return;
|
||||
setAmberStep('idle');
|
||||
setAmberStatus('');
|
||||
toast({ title: 'Connection failed', description: 'Amber did not connect. Try again.', variant: 'destructive' });
|
||||
}
|
||||
}, [login, onLogin, onClose, 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 {
|
||||
setBunkerError('Connection failed. Check your bunker URI and try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtensionLogin = async () => {
|
||||
try {
|
||||
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 = () => {
|
||||
// Show the bunker URI input — the user copies their bunker:// URI from Amber
|
||||
setAmberStep('waiting');
|
||||
setErrors(prev => ({ ...prev, amber: undefined }));
|
||||
};
|
||||
|
||||
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);
|
||||
toast({ title: 'No extension found', description: 'Install a Nostr browser extension like Alby.', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignupClick = () => {
|
||||
onClose();
|
||||
if (onSignup) {
|
||||
onSignup();
|
||||
}
|
||||
const copyUri = () => {
|
||||
navigator.clipboard.writeText(nostrConnectUri).catch(() => {});
|
||||
toast({ title: 'Copied!', description: 'Paste in Amber → Connect apps → Add app' });
|
||||
};
|
||||
|
||||
// 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>
|
||||
<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-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='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 mb-2'>
|
||||
<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' style={{
|
||||
textShadow: '0 0 10px #ccff00'
|
||||
}}>
|
||||
New here?
|
||||
</span>
|
||||
<span className='font-bold text-[#ccff00] uppercase tracking-wide'>New here?</span>
|
||||
</div>
|
||||
<p className='text-sm text-gray-300'>
|
||||
Create a free account in seconds. No email required.
|
||||
</p>
|
||||
<p className='text-xs text-gray-300'>Create a free account. 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'
|
||||
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' />
|
||||
<span>Create Account</span>
|
||||
Create Account
|
||||
</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 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>
|
||||
|
||||
{/* Login Methods */}
|
||||
<Tabs defaultValue={defaultTab} className="w-full">
|
||||
{/* Login tabs — mobile: Amber + Bunker only */}
|
||||
<Tabs defaultValue={isAndroid ? 'amber' : 'bunker'} 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">
|
||||
<TabsTrigger value="amber" className="flex items-center gap-1 text-xs py-2 px-2">
|
||||
<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">
|
||||
<TabsTrigger value="bunker" className="flex items-center gap-1 text-xs py-2 px-2">
|
||||
<Cloud className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Bunker</span>
|
||||
<span className="sm:hidden">Bnk</span>
|
||||
<span>Bunker</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="extension" className="flex items-center gap-1 text-xs py-2 px-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Ext</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>
|
||||
|
||||
{/* ── AMBER TAB ── */}
|
||||
<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]' />
|
||||
{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 mb-1'>
|
||||
Login with Amber
|
||||
</p>
|
||||
<p className='text-xs text-gray-400'>
|
||||
Uses NIP-46 — your keys stay in Amber, we connect via relay. Tap below to get started.
|
||||
<p className='text-sm text-white font-bold'>Login with Amber</p>
|
||||
<p className='text-xs text-gray-400 mt-1'>
|
||||
Tap below — Amber opens, approve once, you're in.
|
||||
</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}
|
||||
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
|
||||
onClick={startAmberConnect}
|
||||
>
|
||||
<Smartphone className="w-4 h-4 mr-2" />
|
||||
{isIOS ? 'Android only' : 'Connect Amber'}
|
||||
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>
|
||||
Need Amber?{' '}
|
||||
<a href="https://zapstore.dev/apps/naddr1qvzqqqr7pvpzqateqake4lc2fn77lflzq30jfpk8uhvtccalc66989er8cdmljceqqdkxmmd9enhyet9deshyaphvvejumn0wd68yumfvahx2usx8zmj2" target="_blank" rel="noopener noreferrer" className="text-[#ccff00] hover:underline">Get it on Zapstore</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Waiting — show bunker URI instructions */}
|
||||
{amberStep === 'waiting' && (
|
||||
<div className='p-4 rounded-lg bg-black/50 border-2 border-[#ccff00]/40 space-y-4'>
|
||||
<div className='text-center'>
|
||||
<p className='text-sm text-white font-bold mb-1'>Connect with Amber (NIP-46)</p>
|
||||
<p className='text-xs text-gray-400'>
|
||||
Get your Bunker URI from Amber and paste it below.
|
||||
</p>
|
||||
{amberStep === 'connecting' && (
|
||||
<div className='p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/40 space-y-4'>
|
||||
<div className='text-center space-y-2'>
|
||||
<Loader2 className='w-8 h-8 text-[#ccff00] animate-spin mx-auto' />
|
||||
<p className='text-sm text-white font-bold'>Waiting for Amber...</p>
|
||||
<p className='text-xs text-gray-400'>{amberStatus}</p>
|
||||
</div>
|
||||
|
||||
{/* Step by step */}
|
||||
<div className='space-y-2 text-xs text-gray-300'>
|
||||
<div className='flex gap-2'>
|
||||
<span className='text-[#ccff00] font-bold min-w-[18px]'>1.</span>
|
||||
<span>Open <strong className='text-white'>Amber</strong> app</span>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<span className='text-[#ccff00] font-bold min-w-[18px]'>2.</span>
|
||||
<span>Tap the key icon → <strong className='text-white'>Connect apps</strong> → <strong className='text-white'>Add app</strong></span>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<span className='text-[#ccff00] font-bold min-w-[18px]'>3.</span>
|
||||
<span>Copy the <strong className='text-white'>bunker://</strong> connection string</span>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<span className='text-[#ccff00] font-bold min-w-[18px]'>4.</span>
|
||||
<span>Paste it below and tap <strong className='text-white'>Connect</strong></span>
|
||||
</div>
|
||||
<div className='text-xs text-gray-400 bg-black/40 rounded-lg p-3 space-y-1'>
|
||||
<p className='font-bold text-white'>If Amber didn't open automatically:</p>
|
||||
<p>1. Open <strong className='text-white'>Amber</strong></p>
|
||||
<p>2. Tap <strong className='text-white'>Connect apps → Paste link</strong></p>
|
||||
<p>3. Paste the connection string below</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder='bunker://...'
|
||||
value={bunkerUri}
|
||||
onChange={e => {
|
||||
setBunkerUri(e.target.value);
|
||||
setErrors(prev => ({ ...prev, amber: undefined }));
|
||||
}}
|
||||
className='bg-black/60 border-[#ccff00]/30 text-white placeholder:text-gray-600 text-xs'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
{errors.amber && (
|
||||
<p className="text-xs text-red-400">{errors.amber}</p>
|
||||
{nostrConnectUri && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className='w-full rounded-full border-[#ccff00]/30 text-[#ccff00] hover:bg-[#ccff00]/10 text-xs h-auto py-2'
|
||||
onClick={copyUri}
|
||||
>
|
||||
<Copy className='w-3 h-3 mr-2' />
|
||||
Copy connection string
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase text-sm h-auto'
|
||||
disabled={isLoading || !bunkerUri.trim().startsWith('bunker://')}
|
||||
onClick={async () => {
|
||||
if (!bunkerUri.trim().startsWith('bunker://')) {
|
||||
setErrors(prev => ({ ...prev, amber: 'Must start with bunker://' }));
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setErrors(prev => ({ ...prev, amber: undefined }));
|
||||
try {
|
||||
await login.bunker(bunkerUri.trim());
|
||||
setAmberStep('success');
|
||||
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
|
||||
setTimeout(() => { onLogin(); onClose(); }, 600);
|
||||
} catch {
|
||||
setErrors(prev => ({ ...prev, amber: 'Connection failed. Check your bunker URI and try again.' }));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? <Loader2 className='w-4 h-4 animate-spin mr-2' /> : null}
|
||||
Connect
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full text-xs text-gray-600 hover:text-gray-400 h-auto py-1"
|
||||
onClick={() => {
|
||||
setAmberStep('initial');
|
||||
setBunkerUri('');
|
||||
setErrors(prev => ({ ...prev, amber: undefined }));
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className='flex-1 text-xs text-gray-500 hover:text-gray-300 h-auto py-2'
|
||||
onClick={() => { abortRef.current?.abort(); setAmberStep('idle'); setAmberStatus(''); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className='flex-1 text-xs text-[#ccff00] hover:text-[#ccff00]/80 h-auto py-2'
|
||||
onClick={startAmberConnect}
|
||||
>
|
||||
<RefreshCw className='w-3 h-3 mr-1' />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</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' />
|
||||
<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-5 h-5 mx-auto animate-spin text-green-500" />
|
||||
<Loader2 className="w-4 h-4 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>
|
||||
|
||||
{/* ── 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'>Login with Bunker (NIP-46)</p>
|
||||
<p className='text-xs text-gray-400 mt-1'>
|
||||
Paste your <code className='text-blue-300'>bunker://</code> connection string from Amber or nsec.app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='text-xs text-gray-400 bg-black/40 rounded-lg p-3 space-y-1'>
|
||||
<p className='font-bold text-white'>From Amber:</p>
|
||||
<p>Settings → NIP-46 Bunker → Create connection → Copy URI</p>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
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'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
spellCheck={false}
|
||||
/>
|
||||
{errors.bunker && (
|
||||
<p className="text-sm text-red-500">{errors.bunker}</p>
|
||||
)}
|
||||
{bunkerError && <p className="text-xs text-red-400">{bunkerError}</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>
|
||||
<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='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 className='text-center pt-1'>
|
||||
<p className='text-xs text-gray-500 mb-2'>Or get a bunker from:</p>
|
||||
<div className='flex gap-2 justify-center flex-wrap'>
|
||||
<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>
|
||||
</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</p>
|
||||
<p className='text-xs text-gray-400 mt-1'>Best for desktop. Works with Alby, nos2x, and similar NIP-07 extensions.</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;
|
||||
|
|
|
|||
|
|
@ -1,69 +1,60 @@
|
|||
import { useNostr } from '@nostrify/react';
|
||||
import { NLogin, useNostrLogin } from '@nostrify/react/login';
|
||||
import { setAmberLogin, clearAmberLogin, openAmberForLogin } from '@/lib/amber';
|
||||
import {
|
||||
NLogin,
|
||||
type NLoginType,
|
||||
type NostrConnectParams,
|
||||
type NostrConnectStatus,
|
||||
useNostrLogin,
|
||||
generateNostrConnectParams,
|
||||
generateNostrConnectURI,
|
||||
} from '@nostrify/react/login';
|
||||
|
||||
// NOTE: This file should not be edited except for adding new login methods.
|
||||
|
||||
/** Custom login type for Amber (NIP-55) */
|
||||
export interface AmberLoginType {
|
||||
id: string;
|
||||
type: 'amber';
|
||||
pubkey: string;
|
||||
createdAt: number;
|
||||
}
|
||||
export type { NostrConnectParams, NostrConnectStatus };
|
||||
export { generateNostrConnectParams, generateNostrConnectURI };
|
||||
|
||||
export function useLoginActions() {
|
||||
const { nostr } = useNostr();
|
||||
const { logins, addLogin, removeLogin } = useNostrLogin();
|
||||
const { logins, addLogin, setLogin, removeLogin } = useNostrLogin();
|
||||
|
||||
const addAndActivate = (login: NLoginType) => {
|
||||
addLogin(login);
|
||||
setLogin(login.id);
|
||||
};
|
||||
|
||||
return {
|
||||
// Login with a Nostr secret key
|
||||
nsec(nsec: string): void {
|
||||
const login = NLogin.fromNsec(nsec);
|
||||
addLogin(login);
|
||||
addAndActivate(login);
|
||||
},
|
||||
// Login with a NIP-46 "bunker://" URI
|
||||
async bunker(uri: string): Promise<void> {
|
||||
const login = await NLogin.fromBunker(uri, nostr);
|
||||
addLogin(login);
|
||||
addAndActivate(login);
|
||||
},
|
||||
// Login with a NIP-07 browser extension
|
||||
async extension(): Promise<void> {
|
||||
const login = await NLogin.fromExtension();
|
||||
addLogin(login);
|
||||
addAndActivate(login);
|
||||
},
|
||||
// Login with Amber (NIP-55) - opens Amber app
|
||||
openAmber(): void {
|
||||
openAmberForLogin();
|
||||
},
|
||||
// Complete Amber login after callback
|
||||
amber(pubkey: string): void {
|
||||
// Store in localStorage for persistence
|
||||
setAmberLogin(pubkey);
|
||||
|
||||
// Create a custom login entry
|
||||
const login: AmberLoginType = {
|
||||
id: `amber_${pubkey}`,
|
||||
type: 'amber',
|
||||
pubkey,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
// Add to the login system (cast to any since it's a custom type)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
addLogin(login as any);
|
||||
// Login via nostrconnect:// (client-initiated NIP-46)
|
||||
// Generates a URI, opens Amber, waits for Amber to connect back via relay.
|
||||
async nostrconnect(
|
||||
params: NostrConnectParams,
|
||||
signal?: AbortSignal,
|
||||
onStatus?: (status: NostrConnectStatus) => void,
|
||||
): Promise<void> {
|
||||
const login = await NLogin.fromNostrConnect(params, nostr, { signal, onStatus });
|
||||
addAndActivate(login);
|
||||
},
|
||||
// Log out the current user
|
||||
async logout(): Promise<void> {
|
||||
const login = logins[0];
|
||||
if (login) {
|
||||
// Clear Amber login from localStorage if it was an Amber login
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((login as any).type === 'amber') {
|
||||
clearAmberLogin();
|
||||
}
|
||||
removeLogin(login.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue