Streamline auth client factory and remove redundant session manager Introduce unified auto rate limit context for seamless status updates Implement problem details in TTS responses for improved error clarity Add rate limit pause controls and banners across document viewers Refine PDF scaling logic with height-aware resize handling Update privacy popup triggers and first-visit modal behavior BREAKING CHANGE: Daily character limits adjusted (anonymous: 250K → 50K, authenticated: 1M → 500K)
242 lines
9 KiB
TypeScript
242 lines
9 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Button, Input } from '@headlessui/react';
|
|
import { useRouter } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { getAuthClient } from '@/lib/auth-client';
|
|
import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
|
|
import { showPrivacyPopup } from '@/components/privacy-popup';
|
|
import { LoadingSpinner } from '@/components/Spinner';
|
|
import toast from 'react-hot-toast';
|
|
|
|
export default function SignUpPage() {
|
|
const router = useRouter();
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [passwordConfirmation, setPasswordConfirmation] = useState('');
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const { authEnabled, baseUrl } = useAuthConfig();
|
|
const { refresh: refreshRateLimit } = useAutoRateLimit();
|
|
|
|
// Check if auth is enabled, redirect home if not
|
|
useEffect(() => {
|
|
if (!authEnabled) {
|
|
router.push('/');
|
|
}
|
|
}, [router, authEnabled]);
|
|
|
|
const validateEmail = (email: string): boolean => {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
};
|
|
|
|
const validatePassword = (password: string) => {
|
|
const checks = {
|
|
length: password.length >= 8,
|
|
uppercase: /[A-Z]/.test(password),
|
|
lowercase: /[a-z]/.test(password),
|
|
number: /\d/.test(password),
|
|
special: /[!@#$%^&*(),.?":{}|<>]/.test(password)
|
|
};
|
|
const strength = Object.values(checks).filter(Boolean).length;
|
|
return { checks, strength };
|
|
};
|
|
|
|
const handleSignUp = async () => {
|
|
setError(null);
|
|
|
|
if (!email.trim() || !validateEmail(email)) {
|
|
setError('Please enter a valid email address');
|
|
return;
|
|
}
|
|
const { strength } = validatePassword(password);
|
|
if (strength < 3) {
|
|
setError('Password is too weak');
|
|
return;
|
|
}
|
|
if (password !== passwordConfirmation) {
|
|
setError('Passwords do not match');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
const client = getAuthClient(baseUrl);
|
|
const result = await client.signUp.email({
|
|
email: email.trim(),
|
|
password,
|
|
name: email.trim().split('@')[0], // Use part of email as name
|
|
});
|
|
|
|
if (result.error) {
|
|
const errorMessage = result.error.message || 'An unknown error occurred';
|
|
if (errorMessage.toLowerCase().includes('already exists')) {
|
|
setError('An account with this email already exists');
|
|
} else {
|
|
setError(errorMessage);
|
|
}
|
|
} else {
|
|
// Auto sign in
|
|
const signInResult = await client.signIn.email({ email: email.trim(), password });
|
|
if (signInResult.error) {
|
|
toast.success('Account created! Please sign in.');
|
|
router.push('/signin');
|
|
} else {
|
|
await refreshRateLimit();
|
|
toast.success('Account created successfully!');
|
|
router.push('/');
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Signup error:', err);
|
|
setError('Unable to connect. Please try again.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
if (!authEnabled) {
|
|
return null;
|
|
}
|
|
|
|
const { checks, strength } = validatePassword(password);
|
|
const strengthLabels = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'];
|
|
const strengthColors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-blue-500', 'bg-green-500'];
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
|
<div className="w-full max-w-md bg-base rounded-2xl shadow-xl p-6">
|
|
<h1 className="text-xl font-semibold text-foreground">Sign Up</h1>
|
|
<p className="text-sm text-muted mt-1">Create your account to get started</p>
|
|
|
|
{error && (
|
|
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
|
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-6 space-y-4">
|
|
{/* Email */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-foreground mb-1">Email</label>
|
|
<Input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="me@example.com"
|
|
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm
|
|
focus:outline-none focus:ring-2 focus:ring-accent"
|
|
/>
|
|
</div>
|
|
|
|
{/* Password */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-foreground mb-1">Password</label>
|
|
<div className="relative">
|
|
<Input
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="Password"
|
|
className="w-full rounded-lg bg-background py-2 px-3 pr-10 text-foreground shadow-sm
|
|
focus:outline-none focus:ring-2 focus:ring-accent"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-foreground"
|
|
>
|
|
{showPassword ? '👁️' : '👁️🗨️'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Password Strength */}
|
|
{password && (
|
|
<div className="mt-2 space-y-1">
|
|
<div className="flex gap-1">
|
|
{[0, 1, 2, 3, 4].map((i) => (
|
|
<div
|
|
key={i}
|
|
className={`h-1 flex-1 rounded ${i < strength ? strengthColors[strength - 1] : 'bg-offbase'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
<p className={`text-xs ${strength >= 3 ? 'text-green-600' : 'text-red-600'}`}>
|
|
{strengthLabels[strength - 1] || 'Very Weak'}
|
|
</p>
|
|
<div className="text-xs space-y-0.5 text-muted">
|
|
{Object.entries(checks).map(([key, passed]) => (
|
|
<div key={key} className={`flex items-center gap-1 ${passed ? 'text-green-600' : ''}`}>
|
|
<span>{passed ? '✓' : '○'}</span>
|
|
<span>
|
|
{key === 'length' && 'At least 8 characters'}
|
|
{key === 'uppercase' && 'Uppercase letter'}
|
|
{key === 'lowercase' && 'Lowercase letter'}
|
|
{key === 'number' && 'Number'}
|
|
{key === 'special' && 'Special character'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Confirm Password */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-foreground mb-1">Confirm Password</label>
|
|
<Input
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={passwordConfirmation}
|
|
onChange={(e) => setPasswordConfirmation(e.target.value)}
|
|
placeholder="Confirm Password"
|
|
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm
|
|
focus:outline-none focus:ring-2 focus:ring-accent"
|
|
/>
|
|
{passwordConfirmation && password && (
|
|
<p className={`text-xs mt-1 ${password === passwordConfirmation ? 'text-green-600' : 'text-red-600'}`}>
|
|
{password === passwordConfirmation ? '✓ Passwords match' : '✗ Passwords do not match'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sign Up Button */}
|
|
<Button
|
|
type="submit"
|
|
disabled={loading}
|
|
onClick={handleSignUp}
|
|
className="w-full rounded-lg bg-accent py-2 text-sm font-medium text-background
|
|
hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent
|
|
focus:ring-offset-2 disabled:opacity-50 transform transition-transform
|
|
duration-200 hover:scale-[1.02]"
|
|
>
|
|
{loading ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Create Account'}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="mt-6 pt-4 border-t border-offbase text-center space-y-2">
|
|
<p className="text-xs text-muted">
|
|
Already have an account?{' '}
|
|
<Link href="/signin" className="underline hover:text-foreground">
|
|
Sign in
|
|
</Link>
|
|
</p>
|
|
<p className="text-xs text-muted">
|
|
By creating an account, you agree to our{' '}
|
|
<button
|
|
onClick={() => showPrivacyPopup()}
|
|
className="underline hover:text-foreground"
|
|
>
|
|
Privacy Policy
|
|
</button>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|