Include login components in stack

This commit is contained in:
Alex Gleason 2025-04-17 21:15:30 -05:00
parent ea0c000ee6
commit 6cfd6f95ba
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
3 changed files with 424 additions and 13 deletions

View File

@ -152,28 +152,55 @@ The `useCurrentUser` hook should be used to ensure that the user is logged in be
### Nostr Login ### Nostr Login
Nostr supports several types of logins: To add Nostr login functionality, use the included `LoginForm` and `SignupForm` dialog components. For example:
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:
```tsx ```tsx
function MyComponent() { import LoginForm from "@/components/auth/LoginForm";
const login = useLoginActions(); import SignupForm from "@/components/auth/SignupForm";
import { Button } from "@/components/ui/button";
login.nsec(nsec); // login by the user pasting their secret key function MyComponent() {
login.bunker(uri); // login by the user pasting a bunker URI const [loginDialogOpen, setLoginDialogOpen] = useState(false);
login.extension(); // login with a NIP-07 browser extension const [signupDialogOpen, setSignupDialogOpen] = useState(false);
const handleLogin = () => {
setLoginDialogOpen(false);
setSignupDialogOpen(false);
};
return ( return (
<div>{/* ... */}</div> <div>
<Button onClick={showLoginDialog}>Log in</Button>
<Button onClick={showSignupDialog}>Sign up</Button>
<LoginForm
isOpen={loginDialogOpen}
onClose={() => setLoginDialogOpen(false)}
onLogin={handleLogin}
onSignup={showSignupDialog}
/>
<SignupForm
isOpen={signupDialogOpen}
onClose={() => setSignupDialogOpen(false)}
/>
</div>
); );
} }
``` ```
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 ## Development Practices
- Uses React Query for data fetching and caching - Uses React Query for data fetching and caching

View File

@ -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<LoginFormProps> = ({ 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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className='sm:max-w-md p-0 overflow-hidden rounded-2xl'>
<DialogHeader className='px-6 pt-6 pb-0 relative'>
<DialogTitle className='text-xl font-semibold text-center'>Log in</DialogTitle>
<DialogDescription className='text-center text-muted-foreground mt-2'>
Access your account securely with your preferred method
</DialogDescription>
</DialogHeader>
<div className='px-6 py-8 space-y-6'>
<Tabs defaultValue={defaultTab} className='w-full'>
<TabsList className='grid grid-cols-3 mb-6'>
<TabsTrigger value='extension'>Extension</TabsTrigger>
<TabsTrigger value='key'>Nsec</TabsTrigger>
<TabsTrigger value='bunker'>Bunker</TabsTrigger>
</TabsList>
<TabsContent value='extension' className='space-y-4'>
<div className='text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800'>
<Shield className='w-12 h-12 mx-auto mb-3 text-primary' />
<p className='text-sm text-gray-600 dark:text-gray-300 mb-4'>
Login with one click using the browser extension
</p>
<Button
className='w-full rounded-full py-6'
onClick={handleExtensionLogin}
disabled={isLoading}
>
{isLoading ? 'Logging in...' : 'Login with Extension'}
</Button>
</div>
</TabsContent>
<TabsContent value='key' className='space-y-4'>
<div className='space-y-4'>
<div className='space-y-2'>
<label htmlFor='nsec' className='text-sm font-medium text-gray-700 dark:text-gray-400'>
Enter your nsec
</label>
<Input
id='nsec'
value={nsec}
onChange={(e) => setNsec(e.target.value)}
className='rounded-lg border-gray-300 dark:border-gray-700 focus-visible:ring-primary'
placeholder='nsec1...'
/>
</div>
<div className='text-center'>
<p className='text-sm mb-2 text-gray-600 dark:text-gray-400'>Or upload a key file</p>
<input
type='file'
accept='.txt'
className='hidden'
ref={fileInputRef}
onChange={handleFileUpload}
/>
<Button
variant='outline'
className='w-full dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700'
onClick={() => fileInputRef.current?.click()}
>
<Upload className='w-4 h-4 mr-2' />
Upload Nsec File
</Button>
</div>
<Button
className='w-full rounded-full py-6 mt-4'
onClick={handleKeyLogin}
disabled={isLoading || !nsec.trim()}
>
{isLoading ? 'Verifying...' : 'Login with Nsec'}
</Button>
</div>
</TabsContent>
<TabsContent value='bunker' className='space-y-4'>
<div className='space-y-2'>
<label htmlFor='bunkerUri' className='text-sm font-medium text-gray-700 dark:text-gray-400'>
Bunker URI
</label>
<Input
id='bunkerUri'
value={bunkerUri}
onChange={(e) => setBunkerUri(e.target.value)}
className='rounded-lg border-gray-300 dark:border-gray-700 focus-visible:ring-primary'
placeholder='bunker://'
/>
{bunkerUri && !bunkerUri.startsWith('bunker://') && (
<p className='text-red-500 text-xs'>URI must start with bunker://</p>
)}
</div>
<Button
className='w-full rounded-full py-6'
onClick={handleBunkerLogin}
disabled={isLoading || !bunkerUri.trim() || !bunkerUri.startsWith('bunker://')}
>
{isLoading ? 'Connecting...' : 'Login with Bunker'}
</Button>
</TabsContent>
</Tabs>
<div className='text-center text-sm'>
<p className='text-gray-600 dark:text-gray-400'>
Don't have an account?{' '}
<button
onClick={handleSignupClick}
className='text-primary hover:underline font-medium'
>
Sign up
</button>
</p>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default LoginForm;

View File

@ -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<SignupFormProps> = ({ 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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className='sm:max-w-md p-0 overflow-hidden rounded-2xl'>
<DialogHeader className='px-6 pt-6 pb-0 relative'>
<DialogTitle className='text-xl font-semibold text-center'>
{step === 'generate' && 'Create Your Account'}
{step === 'download' && 'Download Your Key'}
{step === 'done' && 'Setting Up Your Account'}
</DialogTitle>
<DialogDescription className='text-center text-muted-foreground mt-2'>
{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'}
</DialogDescription>
</DialogHeader>
<div className='px-6 py-8 space-y-6'>
{step === 'generate' && (
<div className='text-center space-y-6'>
<div className='p-4 rounded-lg bg-gray-50 dark:bg-gray-800 flex items-center justify-center'>
<Key className='w-16 h-16 text-primary' />
</div>
<p className='text-sm text-gray-600 dark:text-gray-300'>
We'll generate a secure key for your account. You'll need this key to log in later.
</p>
<Button
className='w-full rounded-full py-6'
onClick={generateKey}
disabled={isLoading}
>
{isLoading ? 'Generating key...' : 'Generate my key'}
</Button>
</div>
)}
{step === 'download' && (
<div className='space-y-6'>
<div className='p-4 rounded-lg border bg-gray-50 dark:bg-gray-800 overflow-auto'>
<code className='text-xs break-all'>{nsecKey}</code>
</div>
<div className='text-sm text-gray-600 dark:text-gray-300 space-y-2'>
<p className='font-medium text-red-500'>Important:</p>
<ul className='list-disc pl-5 space-y-1'>
<li>This is your only way to access your account</li>
<li>Store it somewhere safe</li>
<li>Never share this key with anyone</li>
</ul>
</div>
<div className='flex flex-col space-y-3'>
<Button
variant='outline'
className='w-full'
onClick={downloadKey}
>
<Download className='w-4 h-4 mr-2' />
Download Key
</Button>
<Button
className='w-full rounded-full py-6'
onClick={finishSignup}
>
I've saved my key, continue
</Button>
</div>
</div>
)}
{step === 'done' && (
<div className='flex justify-center items-center py-8'>
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-primary'></div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default SignupForm;