diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index 846f951..23eed77 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -2,18 +2,14 @@ // It is important that all functionality in this file is preserved, and should only be modified if explicitly requested. import React, { useRef, useState } from 'react'; -import { Shield, Upload } from 'lucide-react'; -import { Button } from '@/components/ui/button.tsx'; -import { Input } from '@/components/ui/input.tsx'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog.tsx'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs.tsx'; +import { Shield, Upload, AlertTriangle, Sparkles, Crown, Gem, Star, KeyRound, Lock } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, 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 { cn } from '@/lib/utils'; interface LoginDialogProps { isOpen: boolean; @@ -22,54 +18,108 @@ interface LoginDialogProps { onSignup?: () => void; } +const validateNsec = (nsec: string) => { + return /^nsec1[a-zA-Z0-9]{58}$/.test(nsec); +}; + +const validateBunkerUri = (uri: string) => { + return uri.startsWith('bunker://'); +}; + const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onSignup }) => { const [isLoading, setIsLoading] = useState(false); + const [isFileLoading, setIsFileLoading] = useState(false); const [nsec, setNsec] = useState(''); const [bunkerUri, setBunkerUri] = useState(''); + const [errors, setErrors] = useState<{ + nsec?: string; + bunker?: string; + file?: string; + extension?: string; + }>({}); const fileInputRef = useRef(null); const login = useLoginActions(); - const handleExtensionLogin = () => { + 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.'); } - login.extension(); + await login.extension(); onLogin(); onClose(); - } catch (error) { + } 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 executeLogin = (key: string) => { + setIsLoading(true); + setErrors({}); + + // Use a timeout to allow the UI to update before the synchronous login call + setTimeout(() => { + try { + login.nsec(key); + onLogin(); + onClose(); + } catch { + setErrors({ nsec: "Failed to login with this key. Please check that it's correct." }); + setIsLoading(false); + } + }, 50); + }; + const handleKeyLogin = () => { - if (!nsec.trim()) return; - setIsLoading(true); - - try { - login.nsec(nsec); - onLogin(); - onClose(); - } catch (error) { - console.error('Nsec login failed:', error); - } finally { - setIsLoading(false); + if (!nsec.trim()) { + setErrors(prev => ({ ...prev, nsec: 'Please enter your secret key' })); + return; } + + if (!validateNsec(nsec)) { + setErrors(prev => ({ ...prev, nsec: 'Invalid secret key format. Must be a valid nsec starting with nsec1.' })); + return; + } + executeLogin(nsec); }; - const handleBunkerLogin = () => { - if (!bunkerUri.trim() || !bunkerUri.startsWith('bunker://')) return; + 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 { - login.bunker(bunkerUri); + await login.bunker(bunkerUri); onLogin(); onClose(); - } catch (error) { - console.error('Bunker login failed:', error); + // Clear the URI from memory + setBunkerUri(''); + } catch { + setErrors(prev => ({ + ...prev, + bunker: 'Failed to connect to bunker. Please check the URI.' + })); } finally { setIsLoading(false); } @@ -79,10 +129,27 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onS const file = e.target.files?.[0]; if (!file) return; + setIsFileLoading(true); + setErrors({}); + const reader = new FileReader(); reader.onload = (event) => { + setIsFileLoading(false); const content = event.target?.result as string; - setNsec(content.trim()); + if (content) { + const trimmedContent = content.trim(); + if (validateNsec(trimmedContent)) { + executeLogin(trimmedContent); + } else { + setErrors({ file: 'File does not contain a valid secret key.' }); + } + } else { + setErrors({ file: 'Could not read file content.' }); + } + }; + reader.onerror = () => { + setIsFileLoading(false); + setErrors({ file: 'Failed to read file.' }); }; reader.readAsText(file); }; @@ -94,57 +161,157 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onS } }; + const defaultTab = 'nostr' in window ? 'extension' : 'key'; + return ( - - - Log in - - Access your account securely with your preferred method - + + + + Welcome, Traveler! + + + Start your quest, or login to return to your adventure + +
+ {/* Prominent Sign Up Section */} +
+ {/* Magical sparkles */} +
+ + + +
-
- - - Extension - Nsec - Bunker +
+
+ + + New to Geocaching? + New to the Quest? + +
+ +

+ + Join the guild of adventurers discovering hidden geocaches worldwide! + + + Join the ancient guild of geocache seekers on legendary quests! + +

+ + +
+
+ + {/* Divider */} +
+
+
+
+
+ + Or return to your adventure + +
+
+ + {/* Login Methods */} + + + + + Extension + + + + Key + + + + Bunker + - - + + {errors.extension && ( + + + {errors.extension} + + )}

Login with one click using the browser extension

- +
+ +
-
+ + + +
+
+
+
+
+ + or + +
-

Or upload a key file

= ({ isOpen, onClose, onLogin, onS /> + {errors.file && ( +

{errors.file}

+ )}
- -
- +
- +
+ +
- -
-

- Don't have an account?{' '} - -

-
- ); -}; + ); + }; export default LoginDialog; diff --git a/src/components/auth/SignupDialog.tsx b/src/components/auth/SignupDialog.tsx index 0cf73ea..9ff92b3 100644 --- a/src/components/auth/SignupDialog.tsx +++ b/src/components/auth/SignupDialog.tsx @@ -1,163 +1,792 @@ // 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 } from 'react'; -import { Download, Key } from 'lucide-react'; -import { Button } from '@/components/ui/button.tsx'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog.tsx'; -import { toast } from '@/hooks/useToast.ts'; +import React, { useState, useEffect, useRef } from 'react'; +import { Download, Key, Compass, Scroll, Shield, Crown, Sparkles, MapPin, Gem, Map, Star, Zap, Lock, CheckCircle, Copy, Upload } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Card, CardContent } from '@/components/ui/card'; +import { toast } from '@/hooks/useToast'; import { useLoginActions } from '@/hooks/useLoginActions'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useUploadFile } from '@/hooks/useUploadFile'; import { generateSecretKey, nip19 } from 'nostr-tools'; +import { cn } from '@/lib/utils'; interface SignupDialogProps { isOpen: boolean; onClose: () => void; + onComplete?: () => void; } -const SignupDialog: React.FC = ({ isOpen, onClose }) => { - const [step, setStep] = useState<'generate' | 'download' | 'done'>('generate'); +const sanitizeFilename = (filename: string) => { + return filename.replace(/[^a-z0-9_.-]/gi, '_'); +} + +const SignupDialog: React.FC = ({ isOpen, onClose, onComplete }) => { + const [step, setStep] = useState<'welcome' | 'generate' | 'download' | 'profile' | 'done'>('welcome'); const [isLoading, setIsLoading] = useState(false); const [nsec, setNsec] = useState(''); + const [showSparkles, setShowSparkles] = useState(false); + const [keySecured, setKeySecured] = useState<'none' | 'copied' | 'downloaded'>('none'); + const [profileData, setProfileData] = useState({ + name: '', + about: '', + picture: '' + }); const login = useLoginActions(); + const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); + const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); + const avatarFileInputRef = useRef(null); // Generate a proper nsec key using nostr-tools const generateKey = () => { setIsLoading(true); - - try { - // Generate a new secret key - const sk = generateSecretKey(); - - // Convert to nsec format - setNsec(nip19.nsecEncode(sk)); - setStep('download'); - } catch (error) { - console.error('Failed to generate key:', error); - toast({ - title: 'Error', - description: 'Failed to generate key. Please try again.', - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } + setShowSparkles(true); + + // Add a dramatic pause for the treasure generation effect + setTimeout(() => { + try { + // Generate a new secret key + const sk = generateSecretKey(); + + // Convert to nsec format + setNsec(nip19.nsecEncode(sk)); + setStep('download'); + + toast({ + title: 'Your Treasure Key is Ready!', + description: 'A magical key has been forged just for you.', + }); + } catch { + toast({ + title: 'Error', + description: 'Failed to generate key. Please try again.', + variant: 'destructive', + }); + } finally { + setIsLoading(false); + setShowSparkles(false); + } + }, 2000); }; const downloadKey = () => { - // Create a blob with the key text - const blob = new Blob([nsec], { type: 'text/plain' }); - const url = globalThis.URL.createObjectURL(blob); + try { + // Create a blob with the key text + const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' }); + const url = globalThis.URL.createObjectURL(blob); - // Create a temporary link element and trigger download - const a = document.createElement('a'); - a.href = url; - a.download = 'nsec.txt'; - document.body.appendChild(a); - a.click(); + // Sanitize filename + const filename = sanitizeFilename('treasure-key.txt'); - // Clean up - globalThis.URL.revokeObjectURL(url); - document.body.removeChild(a); + // Create a temporary link element and trigger download + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + // Clean up immediately + globalThis.URL.revokeObjectURL(url); + document.body.removeChild(a); + + // Mark as secured + setKeySecured('downloaded'); + + toast({ + title: 'Treasure Key Secured!', + description: 'Your key has been safely stored in your vault. Guard it well!', + }); + } catch { + toast({ + title: 'Download failed', + description: 'Could not download the key file. Please copy it manually.', + variant: 'destructive', + }); + } + }; + + const copyKey = () => { + navigator.clipboard.writeText(nsec); + setKeySecured('copied'); toast({ - title: 'Key downloaded', - description: 'Your key has been downloaded. Keep it safe!', + title: 'Copied to your spellbook!', + description: 'Key safely transcribed to clipboard', }); }; - const finishSignup = () => { - login.nsec(nsec); - - setStep('done'); - onClose(); - - toast({ - title: 'Account created', - description: 'You are now logged in.', - }); + const finishKeySetup = () => { + try { + login.nsec(nsec); + setStep('profile'); + } catch { + toast({ + title: 'Login Failed', + description: 'Failed to login with the generated key. Please try again.', + variant: 'destructive', + }); + } }; + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Reset file input + e.target.value = ''; + + // Validate file type + if (!file.type.startsWith('image/')) { + toast({ + title: 'Invalid file type', + description: 'Please select an image file for your avatar.', + variant: 'destructive', + }); + return; + } + + // Validate file size (max 5MB) + if (file.size > 5 * 1024 * 1024) { + toast({ + title: 'File too large', + description: 'Avatar image must be smaller than 5MB.', + variant: 'destructive', + }); + return; + } + + try { + const tags = await uploadFile(file); + // Get the URL from the first tag + const url = tags[0]?.[1]; + if (url) { + setProfileData(prev => ({ ...prev, picture: url })); + toast({ + title: 'Avatar uploaded!', + description: 'Your avatar has been uploaded successfully.', + }); + } + } catch { + toast({ + title: 'Upload failed', + description: 'Failed to upload avatar. Please try again.', + variant: 'destructive', + }); + } + }; + + const finishSignup = async (skipProfile = false) => { + // Mark signup completion time for fallback welcome modal + localStorage.setItem('treasures_last_signup', Date.now().toString()); + + try { + // Publish profile if user provided information + if (!skipProfile && (profileData.name || profileData.about || profileData.picture)) { + const metadata: Record = {}; + if (profileData.name) metadata.name = profileData.name; + if (profileData.about) metadata.about = profileData.about; + if (profileData.picture) metadata.picture = profileData.picture; + + await publishEvent({ + kind: 0, + content: JSON.stringify(metadata), + }); + + toast({ + title: 'Profile Created!', + description: 'Your adventurer profile has been set up.', + }); + } + + // Close signup and show welcome modal + onClose(); + if (onComplete) { + // Add a longer delay to ensure login state has fully propagated + setTimeout(() => { + onComplete(); + }, 600); + } else { + // Fallback for when used without onComplete + setStep('done'); + setTimeout(() => { + onClose(); + toast({ + title: 'Welcome to the Adventure!', + description: 'Your quest begins now!', + }); + }, 3000); + } + } catch { + toast({ + title: 'Profile Setup Failed', + description: 'Your account was created but profile setup failed. You can update it later.', + variant: 'destructive', + }); + + // Still proceed to completion even if profile failed + onClose(); + if (onComplete) { + // Add a longer delay to ensure login state has fully propagated + setTimeout(() => { + onComplete(); + }, 600); + } else { + // Fallback for when used without onComplete + setStep('done'); + setTimeout(() => { + onClose(); + toast({ + title: 'Welcome to the Adventure!', + description: 'Your quest begins now!', + }); + }, 3000); + } + } + }; + + const getTitle = () => { + if (step === 'welcome') return ( + + + Begin Your Quest + + ); + if (step === 'generate') return ( + + + Forging Your Key + + ); + if (step === 'download') return ( + + + Secure Your Treasure Key + + ); + if (step === 'profile') return ( + + + Create Your Profile + + ); + return ( + + + Welcome, Adventurer! + + ); + }; + + const getDescription = () => { + if (step === 'welcome') return 'Ready to discover hidden geocaches around the world?'; + if (step === 'generate') return 'Creating your magical key to unlock Treasures'; + if (step === 'download') return 'This key is your passport to adventure - keep it safe!'; + if (step === 'profile') return 'Tell other adventurers about yourself'; + return 'Your adventure begins now!'; + }; + + // Reset state when dialog opens + useEffect(() => { + if (isOpen) { + setStep('welcome'); + setIsLoading(false); + setNsec(''); + setShowSparkles(false); + setKeySecured('none'); + setProfileData({ name: '', about: '', picture: '' }); + } + }, [isOpen]); + + // Add sparkle animation effect + useEffect(() => { + if (showSparkles) { + const interval = setInterval(() => { + // This will trigger re-renders for sparkle animation + }, 100); + return () => clearInterval(interval); + } + }, [showSparkles]); + return ( - - - - {step === 'generate' && 'Create Your Account'} - {step === 'download' && 'Download Your Key'} - {step === 'done' && 'Setting Up Your Account'} + + + + {getTitle()} - - {step === 'generate' && 'Generate a secure key for your account'} - {step === 'download' && "Keep your key safe - you'll need it to log in"} - {step === 'done' && 'Finalizing your account setup'} + + {getDescription()} +
+ {/* Welcome Step - New engaging introduction */} + {step === 'welcome' && ( +
+ {/* Hero illustration */} +
+
+
+ + +
+ +
+ + +
+
-
- {step === 'generate' && ( -
-
- + {/* Adventure benefits */} +
+
+ + Embark on legendary quests worldwide +
+
+ + Hide your own geocaches +
+
+ + Unite with fellow adventurers +
+
+
+ +
+

+ Join adventurers exploring the world through geocaching. + Your quest begins with forging your very own treasure key. +

+ + + +

+ Free forever • Decentralized • Your data, your control +

-

- We'll generate a secure key for your account. You'll need this key to log in later. -

-
)} + {/* Generate Step - Enhanced with animations */} + {step === 'generate' && ( +
+
+ {/* Animated background elements */} + {showSparkles && ( +
+ {[...Array(12)].map((_, i) => ( + + ))} +
+ )} + +
+ {isLoading ? ( +
+
+ +
+
+
+
+
+

+ + Forging your magical key... +

+

+ Weaving cryptographic spells +

+
+
+ ) : ( +
+ +
+

+ Ready to forge your treasure key? +

+

+ This magical key will be your passport to the world of Treasures. +

+

+ It's completely unique and secure - keep it secret, keep it safe! +

+
+
+ )} +
+
+ + {!isLoading && ( + + )} +
+ )} + + {/* Download Step - Whimsical and magical */} {step === 'download' && ( -
-
- {nsec} +
+ {/* Magical treasure chest reveal */} +
+ {/* Magical sparkles floating around */} +
+ + + + +
+ +
+
+
+ +
+
+ +
+
+
+ +
+

+ Behold! Your magical treasure key! +

+ + {/* Whimsical warning with scroll design */} +
+
+
+ + + Ancient Warning + +
+

+"Guard this key with your life, for once lost to the digital winds, it shall never return..." +

+
+
+
-
-

Important:

-
    -
  • This is your only way to access your account
  • -
  • Store it somewhere safe
  • -
  • Never share this key with anyone
  • -
+ {/* Enchanted key vault */} +
+
+ + + Your Treasure Key + +
+
+ {nsec} +
-
+ {/* Security options - clearly presented as choices */} +
+
+

+ Choose how to secure your key: +

+
+ +
+ {/* Copy Option */} + + + + + + + {/* Download Option */} + + + + + +
+ + {/* Continue button - blocked until key is secured */} + +
+
+ )} + + {/* Profile Step - Optional profile setup */} + {step === 'profile' && ( +
+ {/* Profile setup illustration */} +
+ {/* Magical sparkles */} +
+ + + +
+ +
+
+
+ +
+
+ +
+
+
+ +
+

+ Almost there! Let's set up your profile +

+ +

+ Your legend starts here +

+
+
+ + {/* Publishing status indicator */} + {isPublishing && ( +
+
+
+ + Publishing your profile to the realm... + +
+
+ )} + + {/* Profile form */} +
+
+ + setProfileData(prev => ({ ...prev, name: e.target.value }))} + placeholder='Your name' + className='rounded-lg' + disabled={isPublishing} + /> +
+ +
+ +