feat: Implement secure email/password authentication with user registration, API rate limiting, and dynamic homepage navigation.

This commit is contained in:
Yusuf İpek
2026-02-07 06:43:09 +03:00
parent d38e8b8749
commit 5b436fff2d
11 changed files with 707 additions and 166 deletions
+11 -1
View File
@@ -1,7 +1,17 @@
export default function AuthLayout({
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
export default async function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
// If already logged in, redirect to dashboard
if (session?.user) {
redirect('/dashboard');
}
return <>{children}</>;
}
+122 -116
View File
@@ -1,51 +1,146 @@
'use client';
import { useState } from 'react';
import { useState, useEffect, Suspense } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Video, Loader2, Github, Mail } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Video, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { signIn } from 'next-auth/react';
export default function LoginPage() {
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showSuccess, setShowSuccess] = useState(false);
useEffect(() => {
if (searchParams.get('registered') === 'true') {
setShowSuccess(true);
}
}, [searchParams]);
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
// TODO: Implement actual sign in
// await signIn('credentials', { email, password, redirect: false });
await new Promise(resolve => setTimeout(resolve, 500));
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('Invalid email or password');
return;
}
router.push('/dashboard');
} catch (error) {
console.error('Login failed:', error);
router.refresh();
} catch {
setError('Something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleOAuthLogin = async (provider: string) => {
setIsLoading(true);
try {
// TODO: Implement OAuth
// await signIn(provider, { callbackUrl: '/dashboard' });
await new Promise(resolve => setTimeout(resolve, 500));
router.push('/dashboard');
} catch (error) {
console.error('OAuth login failed:', error);
} finally {
setIsLoading(false);
}
};
return (
<Card>
<CardHeader className="text-center">
<CardTitle>Welcome back</CardTitle>
<CardDescription>
Sign in to your account to continue
</CardDescription>
</CardHeader>
<CardContent>
{showSuccess && (
<div className="p-3 rounded-md bg-green-500/10 text-green-600 text-sm mb-4">
Account created successfully! Please sign in.
</div>
)}
{/* Email Form */}
<form onSubmit={handleEmailLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="[email protected]"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError('');
}}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError('');
}}
required
disabled={isLoading}
/>
</div>
{error && (
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Sign in
</Button>
</form>
<p className="text-center text-sm text-muted-foreground mt-6">
Don&apos;t have an account?{' '}
<Link href="/register" className="text-primary hover:underline">
Sign up
</Link>
</p>
</CardContent>
</Card>
);
}
function LoginFormSkeleton() {
return (
<Card>
<CardHeader className="text-center">
<CardTitle>Welcome back</CardTitle>
<CardDescription>
Sign in to your account to continue
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="h-10 bg-muted animate-pulse rounded-md" />
<div className="h-10 bg-muted animate-pulse rounded-md" />
<div className="h-10 bg-primary/20 animate-pulse rounded-md" />
</CardContent>
</Card>
);
}
export default function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-md">
@@ -55,98 +150,9 @@ export default function LoginPage() {
<span className="font-bold text-2xl">OpenFrame</span>
</Link>
<Card>
<CardHeader className="text-center">
<CardTitle>Welcome back</CardTitle>
<CardDescription>
Sign in to your account to continue
</CardDescription>
</CardHeader>
<CardContent>
{/* OAuth Buttons */}
<div className="grid gap-2">
<Button
variant="outline"
onClick={() => handleOAuthLogin('google')}
disabled={isLoading}
>
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
<Button
variant="outline"
onClick={() => handleOAuthLogin('github')}
disabled={isLoading}
>
<Github className="h-4 w-4 mr-2" />
Continue with GitHub
</Button>
</div>
<div className="relative my-6">
<Separator />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
or continue with email
</span>
</div>
{/* Email Form */}
<form onSubmit={handleEmailLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="[email protected]"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Sign in
</Button>
</form>
<p className="text-center text-sm text-muted-foreground mt-6">
Don&apos;t have an account?{' '}
<Link href="/register" className="text-primary hover:underline">
Sign up
</Link>
</p>
</CardContent>
</Card>
<Suspense fallback={<LoginFormSkeleton />}>
<LoginForm />
</Suspense>
<p className="text-center text-xs text-muted-foreground mt-4">
By continuing, you agree to our Terms of Service and Privacy Policy
+208
View File
@@ -0,0 +1,208 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Video, Loader2, KeyRound, UserPlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export default function RegisterPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
inviteCode: '',
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value,
}));
setError('');
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
// Client-side validation
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match');
setIsLoading(false);
return;
}
if (formData.password.length < 8) {
setError('Password must be at least 8 characters');
setIsLoading(false);
return;
}
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
email: formData.email,
password: formData.password,
inviteCode: formData.inviteCode,
}),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Registration failed');
return;
}
// Redirect to login on success
router.push('/login?registered=true');
} catch {
setError('Something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-md">
{/* Logo */}
<Link href="/" className="flex items-center justify-center gap-2 mb-8">
<Video className="h-8 w-8 text-primary" />
<span className="font-bold text-2xl">OpenFrame</span>
</Link>
<Card>
<CardHeader className="text-center">
<CardTitle className="flex items-center justify-center gap-2">
<UserPlus className="h-5 w-5" />
Create Account
</CardTitle>
<CardDescription>
Join OpenFrame to collaborate on video projects
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleRegister} className="space-y-4">
{/* Invite Code - First and prominent */}
<div className="space-y-2">
<Label htmlFor="inviteCode" className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-amber-500" />
Invite Code
</Label>
<Input
id="inviteCode"
name="inviteCode"
type="text"
placeholder="Enter your invite code"
value={formData.inviteCode}
onChange={handleChange}
required
disabled={isLoading}
className="border-amber-500/30 focus:border-amber-500"
/>
<p className="text-xs text-muted-foreground">
An invite code is required to create an account
</p>
</div>
<div className="h-px bg-border my-4" />
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
name="name"
type="text"
placeholder="John Doe"
value={formData.name}
onChange={handleChange}
required
disabled={isLoading}
minLength={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="[email protected]"
value={formData.email}
onChange={handleChange}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="••••••••"
value={formData.password}
onChange={handleChange}
required
disabled={isLoading}
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
placeholder="••••••••"
value={formData.confirmPassword}
onChange={handleChange}
required
disabled={isLoading}
/>
</div>
{error && (
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Create Account
</Button>
</form>
<p className="text-center text-sm text-muted-foreground mt-6">
Already have an account?{' '}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</CardContent>
</Card>
<p className="text-center text-xs text-muted-foreground mt-4">
By continuing, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { checkRateLimit, getClientIp, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit';
export async function POST(request: NextRequest) {
try {
// Rate limiting by IP
const clientIp = getClientIp(request);
const rateLimitKey = `register:${clientIp}`;
const rateLimit = await checkRateLimit(rateLimitKey, 'register');
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many registration attempts. Please try again later.' },
{
status: 429,
headers: rateLimitHeaders(rateLimit, RATE_LIMIT_CONFIGS.register.maxRequests),
}
);
}
const body = await request.json();
const { name, email, password, inviteCode } = body;
// Validate invite code using constant-time comparison to prevent timing attacks
const validInviteCode = process.env.INVITE_CODE;
if (!validInviteCode || !inviteCode) {
return NextResponse.json(
{ error: 'Invalid invite code' },
{ status: 403 }
);
}
// Constant-time comparison
const { timingSafeEqual } = await import('crypto');
const validBuffer = Buffer.from(validInviteCode);
const providedBuffer = Buffer.from(String(inviteCode));
// Ensure same length for comparison (prevents length-based timing leak)
const isValidLength = validBuffer.length === providedBuffer.length;
const compareBuffer = isValidLength ? providedBuffer : validBuffer;
const isValidCode = isValidLength && timingSafeEqual(validBuffer, compareBuffer);
if (!isValidCode) {
return NextResponse.json(
{ error: 'Invalid invite code' },
{ status: 403 }
);
}
// Validate required fields
if (!name || typeof name !== 'string' || name.trim().length < 2) {
return NextResponse.json(
{ error: 'Name must be at least 2 characters' },
{ status: 400 }
);
}
if (!email || typeof email !== 'string') {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email format' },
{ status: 400 }
);
}
if (!password || typeof password !== 'string' || password.length < 8) {
return NextResponse.json(
{ error: 'Password must be at least 8 characters' },
{ status: 400 }
);
}
// Check if email already exists
const existingUser = await db.user.findUnique({
where: { email: email.toLowerCase() },
});
if (existingUser) {
return NextResponse.json(
{ error: 'An account with this email already exists' },
{ status: 409 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const user = await db.user.create({
data: {
name: name.trim(),
email: email.toLowerCase(),
password: hashedPassword,
},
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
});
const response = NextResponse.json(
{ message: 'Account created successfully', user },
{ status: 201 }
);
// Add rate limit headers to successful response
const headers = rateLimitHeaders(rateLimit, RATE_LIMIT_CONFIGS.register.maxRequests);
Object.entries(headers).forEach(([key, value]) => {
response.headers.set(key, value);
});
return response;
} catch (error) {
console.error('Registration error:', error);
return NextResponse.json(
{ error: 'Failed to create account' },
{ status: 500 }
);
}
}
+47 -21
View File
@@ -1,8 +1,12 @@
import Link from 'next/link';
import { Video, MessageSquare, Mic, Share2, ArrowRight, Play } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { auth } from '@/lib/auth';
export default async function HomePage() {
const session = await auth();
const isLoggedIn = !!session?.user;
export default function HomePage() {
return (
<div className="min-h-screen bg-background">
{/* Header */}
@@ -13,12 +17,25 @@ export default function HomePage() {
<span className="font-bold text-xl">OpenFrame</span>
</Link>
<div className="flex items-center gap-4">
<Button asChild variant="ghost">
<Link href="/login">Sign in</Link>
</Button>
<Button asChild>
<Link href="/dashboard">Get Started</Link>
</Button>
{isLoggedIn ? (
<>
<Button asChild variant="ghost">
<Link href="/dashboard">Dashboard</Link>
</Button>
<Button asChild>
<Link href="/projects/new">New Project</Link>
</Button>
</>
) : (
<>
<Button asChild variant="ghost">
<Link href="/login">Sign in</Link>
</Button>
<Button asChild>
<Link href="/register">Get Started</Link>
</Button>
</>
)}
</div>
</div>
</header>
@@ -31,16 +48,25 @@ export default function HomePage() {
<span className="text-primary">reimagined</span>
</h1>
<p className="text-xl text-muted-foreground max-w-2xl">
Collect timestamped feedback on your videos with text and voice comments.
Collect timestamped feedback on your videos with text and voice comments.
Share with your team and clients, iterate faster.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button asChild size="lg">
<Link href="/dashboard">
Start for free
<ArrowRight className="h-4 w-4 ml-2" />
</Link>
</Button>
{isLoggedIn ? (
<Button asChild size="lg">
<Link href="/dashboard">
Go to Dashboard
<ArrowRight className="h-4 w-4 ml-2" />
</Link>
</Button>
) : (
<Button asChild size="lg">
<Link href="/register">
Start for free
<ArrowRight className="h-4 w-4 ml-2" />
</Link>
</Button>
)}
<Button asChild variant="outline" size="lg">
<Link href="#features">
<Play className="h-4 w-4 mr-2" />
@@ -88,8 +114,8 @@ export default function HomePage() {
Join teams who have already switched to OpenFrame for faster, clearer video feedback.
</p>
<Button asChild size="lg">
<Link href="/dashboard">
Get started for free
<Link href={isLoggedIn ? '/dashboard' : '/register'}>
{isLoggedIn ? 'Go to Dashboard' : 'Get started for free'}
<ArrowRight className="h-4 w-4 ml-2" />
</Link>
</Button>
@@ -112,11 +138,11 @@ export default function HomePage() {
);
}
function FeatureCard({
icon: Icon,
title,
description
}: {
function FeatureCard({
icon: Icon,
title,
description
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;