diff --git a/CONTEXT.md b/CONTEXT.md index d07ea2b..b9d6b82 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -152,28 +152,55 @@ The `useCurrentUser` hook should be used to ensure that the user is logged in be ### Nostr Login -Nostr supports several types of logins: - -1. Login with nsec -2. Login with browser extension -3. Login with bunker URI - -Functions to log in with each of these methods are exposed by the `useLoginActions` hook: +To add Nostr login functionality, use the included `LoginForm` and `SignupForm` dialog components. For example: ```tsx -function MyComponent() { - const login = useLoginActions(); +import LoginForm from "@/components/auth/LoginForm"; +import SignupForm from "@/components/auth/SignupForm"; +import { Button } from "@/components/ui/button"; - login.nsec(nsec); // login by the user pasting their secret key - login.bunker(uri); // login by the user pasting a bunker URI - login.extension(); // login with a NIP-07 browser extension +function MyComponent() { + const [loginDialogOpen, setLoginDialogOpen] = useState(false); + const [signupDialogOpen, setSignupDialogOpen] = useState(false); + + const handleLogin = () => { + setLoginDialogOpen(false); + setSignupDialogOpen(false); + }; return ( -
{/* ... */}
+
+ + + + setLoginDialogOpen(false)} + onLogin={handleLogin} + onSignup={showSignupDialog} + /> + + setSignupDialogOpen(false)} + /> +
); } ``` +To access the currently-logged-in account, use the `useCurrentUser` hook, eg: + +```typescript +import { useCurrentUser } from "@/hooks/useCurrentUser"; + +function MyComponent() { + const { user } = useCurrentUser(); + + // ... +} +``` + ## Development Practices - Uses React Query for data fetching and caching diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..9d86e9b --- /dev/null +++ b/src/components/auth/LoginForm.tsx @@ -0,0 +1,223 @@ +import React, { useEffect, 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 { useLoginActions } from '@/hooks/useLoginActions'; + +interface LoginFormProps { + isOpen: boolean; + onClose: () => void; + onLogin: () => void; + onSignup?: () => void; +} + +const LoginForm: React.FC = ({ isOpen, onClose, onLogin, onSignup }) => { + const [isLoading, setIsLoading] = useState(false); + const [nsec, setNsec] = useState(''); + const [bunkerUri, setBunkerUri] = useState(''); + const [defaultTab, setDefaultTab] = useState('extension'); + const fileInputRef = useRef(null); + const login = useLoginActions(); + + // Check if Nostr extension exists on component mount + useEffect(() => { + const hasNostrExtension = 'nostr' in window; + setDefaultTab(hasNostrExtension ? 'extension' : 'key'); + }, []); + + const handleExtensionLogin = () => { + setIsLoading(true); + try { + if (!('nostr' in window)) { + throw new Error('Nostr extension not found. Please install a NIP-07 extension.'); + } + login.extension(); + onLogin(); + onClose(); + } catch (error) { + console.error('Extension login failed:', error); + } finally { + setIsLoading(false); + } + }; + + 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); + } + }; + + const handleBunkerLogin = () => { + if (!bunkerUri.trim() || !bunkerUri.startsWith('bunker://')) return; + setIsLoading(true); + + try { + login.bunker(bunkerUri); + onLogin(); + onClose(); + } catch (error) { + console.error('Bunker login failed:', error); + } finally { + setIsLoading(false); + } + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + const content = event.target?.result as string; + setNsec(content.trim()); + }; + reader.readAsText(file); + }; + + const handleSignupClick = () => { + onClose(); + if (onSignup) { + onSignup(); + } + }; + + return ( + + + + Log in + + Access your account securely with your preferred method + + + +
+ + + Extension + Nsec + Bunker + + + +
+ +

+ Login with one click using the browser extension +

+ +
+
+ + +
+
+ + setNsec(e.target.value)} + className='rounded-lg border-gray-300 dark:border-gray-700 focus-visible:ring-primary' + placeholder='nsec1...' + /> +
+ +
+

Or upload a key file

+ + +
+ + +
+
+ + +
+ + setBunkerUri(e.target.value)} + className='rounded-lg border-gray-300 dark:border-gray-700 focus-visible:ring-primary' + placeholder='bunker://' + /> + {bunkerUri && !bunkerUri.startsWith('bunker://') && ( +

URI must start with bunker://

+ )} +
+ + +
+
+ +
+

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

+
+
+
+
+ ); +}; + +export default LoginForm; diff --git a/src/components/auth/SignupForm.tsx b/src/components/auth/SignupForm.tsx new file mode 100644 index 0000000..e229d7f --- /dev/null +++ b/src/components/auth/SignupForm.tsx @@ -0,0 +1,161 @@ +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 { generateSecretKey, nip19 } from 'nostr-tools'; + +interface SignupFormProps { + isOpen: boolean; + onClose: () => void; +} + +const SignupForm: React.FC = ({ isOpen, onClose }) => { + const [step, setStep] = useState<'generate' | 'download' | 'done'>('generate'); + const [isLoading, setIsLoading] = useState(false); + const [nsecKey, setNsecKey] = useState(''); + + // Generate a proper nsec key using nostr-tools + const generateKey = () => { + setIsLoading(true); + + try { + // Generate a new private key + const privateKey = generateSecretKey(); + + // Convert to nsec format + const nsec = nip19.nsecEncode(privateKey); + setNsecKey(nsec); + 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); + } + }; + + const downloadKey = () => { + // Create a blob with the key text + const blob = new Blob([nsecKey], { type: 'text/plain' }); + 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(); + + // Clean up + globalThis.URL.revokeObjectURL(url); + document.body.removeChild(a); + + toast({ + title: 'Key downloaded', + description: 'Your key has been downloaded. Keep it safe!', + }); + }; + + const finishSignup = () => { + setStep('done'); + onClose(); + + toast({ + title: 'Account created', + description: 'You are now logged in.', + }); + }; + + return ( + + + + + {step === 'generate' && 'Create Your Account'} + {step === 'download' && 'Download Your Key'} + {step === 'done' && 'Setting Up Your Account'} + + + {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'} + + + +
+ {step === 'generate' && ( +
+
+ +
+

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

+ +
+ )} + + {step === 'download' && ( +
+
+ {nsecKey} +
+ +
+

Important:

+
    +
  • This is your only way to access your account
  • +
  • Store it somewhere safe
  • +
  • Never share this key with anyone
  • +
+
+ +
+ + + +
+
+ )} + + {step === 'done' && ( +
+
+
+ )} +
+
+
+ ); +}; + +export default SignupForm;