mkstack/src/components/auth/LoginDialog.tsx
2025-07-14 21:12:26 +00:00

377 lines
13 KiB
TypeScript

// 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, { useRef, useState, useEffect } from 'react';
import { Shield, Upload, AlertTriangle, UserPlus, KeyRound, Sparkles, Cloud } from 'lucide-react';
import { Button } from '@/components/ui/button';
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 { cn } from '@/lib/utils';
interface LoginDialogProps {
isOpen: boolean;
onClose: () => void;
onLogin: () => void;
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<LoginDialogProps> = ({ 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<HTMLInputElement>(null);
const login = useLoginActions();
// Reset all state when dialog opens/closes
useEffect(() => {
if (isOpen) {
// Reset state when dialog opens
setIsLoading(false);
setIsFileLoading(false);
setNsec('');
setBunkerUri('');
setErrors({});
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
}, [isOpen]);
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.');
}
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 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()) {
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 = 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);
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
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;
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);
};
const handleSignupClick = () => {
onClose();
if (onSignup) {
onSignup();
}
};
const defaultTab = 'nostr' in window ? 'extension' : 'key';
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className={cn("max-w-[95vw] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl overflow-y-scroll")}
>
<DialogHeader className={cn('px-6 pt-6 pb-1 relative')}>
<DialogDescription className="text-center">
Sign up or log in to continue
</DialogDescription>
</DialogHeader>
<div className='px-6 pt-2 pb-4 space-y-4 overflow-y-auto flex-1'>
{/* Prominent Sign Up Section */}
<div className='relative p-4 rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-950/50 dark:to-indigo-950/50 border border-blue-200 dark:border-blue-800 overflow-hidden'>
<div className='relative z-10 text-center space-y-3'>
<div className='flex justify-center items-center gap-2 mb-2'>
<Sparkles className='w-5 h-5 text-purple-600' />
<span className='font-semibold text-purple-800 dark:text-purple-200'>
New to Nostr?
</span>
</div>
<p className='text-sm text-purple-700/80 dark:text-purple-300/80'>
Create a new account to get started. It's free and open.
</p>
<Button
onClick={handleSignupClick}
className='w-full rounded-full py-3 text-base font-semibold bg-gradient-to-r from-purple-600 to-yellow-600 hover:from-purple-700 hover:to-yellow-700 transform transition-all duration-200 hover:scale-105 shadow-lg border-0'
>
<UserPlus className='w-4 h-4 mr-2' />
<span>Sign Up</span>
</Button>
</div>
</div>
{/* Divider */}
<div className='relative'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-t border-gray-300 dark:border-gray-600'></div>
</div>
<div className='relative flex justify-center text-sm'>
<span className='px-3 bg-background text-muted-foreground'>
<span>Or log in</span>
</span>
</div>
</div>
{/* Login Methods */}
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-muted/80 rounded-lg mb-4">
<TabsTrigger value="extension" className="flex items-center gap-2">
<Shield className="w-4 h-4" />
<span>Extension</span>
</TabsTrigger>
<TabsTrigger value="key" className="flex items-center gap-2">
<KeyRound className="w-4 h-4" />
<span>Key</span>
</TabsTrigger>
<TabsTrigger value="bunker" className="flex items-center gap-2">
<Cloud className="w-4 h-4" />
<span>Bunker</span>
</TabsTrigger>
</TabsList>
<TabsContent value='extension' className='space-y-3 bg-muted'>
{errors.extension && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{errors.extension}</AlertDescription>
</Alert>
)}
<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>
<div className="flex justify-center">
<Button
className='w-full rounded-full py-4'
onClick={handleExtensionLogin}
disabled={isLoading}
>
{isLoading ? 'Logging in...' : 'Login with Extension'}
</Button>
</div>
</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'>
Secret Key (nsec)
</label>
<Input
id='nsec'
type="password"
value={nsec}
onChange={(e) => {
setNsec(e.target.value);
if (errors.nsec) setErrors(prev => ({ ...prev, nsec: undefined }));
}}
className={`rounded-lg ${
errors.nsec ? 'border-red-500 focus-visible:ring-red-500' : ''
}`}
placeholder='nsec1...'
autoComplete="off"
/>
{errors.nsec && (
<p className="text-sm text-red-500">{errors.nsec}</p>
)}
</div>
<Button
className='w-full rounded-full py-3'
onClick={handleKeyLogin}
disabled={isLoading || !nsec.trim()}
>
{isLoading ? 'Verifying...' : 'Log In'}
</Button>
<div className='relative'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-t border-muted'></div>
</div>
<div className='relative flex justify-center text-xs'>
<span className='px-2 bg-background text-muted-foreground'>
or
</span>
</div>
</div>
<div className='text-center'>
<input
type='file'
accept='.txt'
className='hidden'
ref={fileInputRef}
onChange={handleFileUpload}
/>
<Button
variant='outline'
className='w-full'
onClick={() => fileInputRef.current?.click()}
disabled={isLoading || isFileLoading}
>
<Upload className='w-4 h-4 mr-2' />
{isFileLoading ? 'Reading File...' : 'Upload Your Key File'}
</Button>
{errors.file && (
<p className="text-sm text-red-500 mt-2">{errors.file}</p>
)}
</div>
</div>
</TabsContent>
<TabsContent value='bunker' className='space-y-3 bg-muted'>
<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);
if (errors.bunker) setErrors(prev => ({ ...prev, bunker: undefined }));
}}
className={`rounded-lg border-gray-300 dark:border-gray-700 focus-visible:ring-primary ${
errors.bunker ? 'border-red-500' : ''
}`}
placeholder='bunker://'
autoComplete="off"
/>
{errors.bunker && (
<p className="text-sm text-red-500">{errors.bunker}</p>
)}
</div>
<div className="flex justify-center">
<Button
className='w-full rounded-full py-4'
onClick={handleBunkerLogin}
disabled={isLoading || !bunkerUri.trim()}
>
{isLoading ? 'Connecting...' : 'Login with Bunker'}
</Button>
</div>
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
);
};
export default LoginDialog;