diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index 379c57a..0457ccd 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -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 = ({ isOpen, onClose, onLogin, onSignup }) => { +export const LoginDialog: React.FC = ({ 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 | 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(null); + const abortRef = useRef(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 ( - - - - - Sign up or log in to continue - + + + + Sign up or log in to continue + -
- {/* Prominent Sign Up Section */} -
- {/* Neon glow effect */} -
+
+ {/* Sign Up */} +
+
-
+
- - New here? - + New here?
-

- Create a free account in seconds. No email required. -

+

Create a free account. No email required.

{/* Divider */} -
-
-
-
-
- - Or log in - -
+
+
+ Or log in +
- {/* Login Methods */} - + {/* Login tabs — mobile: Amber + Bunker only */} + - - - Extension - Ext - - + Amber - + - Bunker - Bnk + Bunker + + + + Ext - - {errors.extension && ( - - - - No extension detected. Try using the Create Account button above{isAndroid ? ', or use Amber on Android' : isIOS ? ', or use Bunker for iOS' : ''}. - - - )} -
- -

- {isMobile ? 'Browser Extension' : 'One-Click Login (Desktop)'} -

-

- {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.' - } -

-
- -
-
-
+ {/* ── AMBER TAB ── */} - {isIOS && ( - - - - Amber is Android-only. For iOS, use Bunker (nsec.app or Nostrum). - - - )} - - {/* Initial state — single tap */} - {amberStep === 'initial' && ( -
-
- + {amberStep === 'idle' && ( +
+
+
-

- Login with Amber -

-

- Uses NIP-46 — your keys stay in Amber, we connect via relay. Tap below to get started. +

Login with Amber

+

+ Tap below — Amber opens, approve once, you're in.

- Don't have Amber?{' '} - - Download on GitHub - - {' '}or{' '} - - F-Droid - + Need Amber?{' '} + Get it on Zapstore

)} - {/* Waiting — show bunker URI instructions */} - {amberStep === 'waiting' && ( -
-
-

Connect with Amber (NIP-46)

-

- Get your Bunker URI from Amber and paste it below. -

+ {amberStep === 'connecting' && ( +
+
+ +

Waiting for Amber...

+

{amberStatus}

- {/* Step by step */} -
-
- 1. - Open Amber app -
-
- 2. - Tap the key icon → Connect apps → Add app -
-
- 3. - Copy the bunker:// connection string -
-
- 4. - Paste it below and tap Connect -
+
+

If Amber didn't open automatically:

+

1. Open Amber

+

2. Tap Connect apps → Paste link

+

3. Paste the connection string below

- { - 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 && ( -

{errors.amber}

+ {nostrConnectUri && ( + )} - - - +
+ + +
)} - {/* Success */} {amberStep === 'success' && ( -
- +
+

Logged in!

- +
)} - - {isIOS && ( - - - - Recommended for iOS! Use nsec.app for the best experience on iPhone/iPad. - - - )} -
- -

- Login with Nostr Connect {isIOS && '(Best for iOS)'} -

-

- {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.' - } -

- + {/* ── BUNKER TAB ── */} + +
+
+ +

Login with Bunker (NIP-46)

+

+ Paste your bunker:// connection string from Amber or nsec.app. +

+
+ +
+

From Amber:

+

Settings → NIP-46 Bunker → Create connection → Copy URI

+
+
- { - 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 && ( -

{errors.bunker}

- )} + {bunkerError &&

{bunkerError}

}
-
- -
+ -
-

Get a bunker connection from:

- + + {/* ── EXTENSION TAB ── */} + +
+ +
+

Browser Extension

+

Best for desktop. Works with Alby, nos2x, and similar NIP-07 extensions.

+
+ +
+
- ); - }; + ); +}; export default LoginDialog; diff --git a/src/hooks/useLoginActions.ts b/src/hooks/useLoginActions.ts index df5bf2e..c6ecc5b 100644 --- a/src/hooks/useLoginActions.ts +++ b/src/hooks/useLoginActions.ts @@ -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 { const login = await NLogin.fromBunker(uri, nostr); - addLogin(login); + addAndActivate(login); }, // Login with a NIP-07 browser extension async extension(): Promise { 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 { + const login = await NLogin.fromNostrConnect(params, nostr, { signal, onStatus }); + addAndActivate(login); }, // Log out the current user async logout(): Promise { 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); } - } + }, }; }