mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-08-26 20:49:22 +00:00
Include login components in stack
This commit is contained in:
parent
ea0c000ee6
commit
6cfd6f95ba
53
CONTEXT.md
53
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 (
|
||||
<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
|
||||
|
||||
- Uses React Query for data fetching and caching
|
||||
|
223
src/components/auth/LoginForm.tsx
Normal file
223
src/components/auth/LoginForm.tsx
Normal 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;
|
161
src/components/auth/SignupForm.tsx
Normal file
161
src/components/auth/SignupForm.tsx
Normal 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;
|
Loading…
x
Reference in New Issue
Block a user