mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: Implement secure email/password authentication with user registration, API rate limiting, and dynamic homepage navigation.
This commit is contained in:
+11
-1
@@ -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}</>;
|
||||
}
|
||||
|
||||
+121
-115
@@ -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'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'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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+41
-15
@@ -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>
|
||||
@@ -35,12 +52,21 @@ export default function HomePage() {
|
||||
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>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"@base-ui/react": "^1.1.0",
|
||||
"@prisma/adapter-pg": "^7.3.0",
|
||||
"@prisma/client": "^7.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -29,6 +30,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "^19",
|
||||
@@ -489,6 +491,8 @@
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/bcryptjs": ["@types/[email protected]", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
|
||||
|
||||
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/json-schema": ["@types/[email protected]", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
@@ -623,6 +627,8 @@
|
||||
|
||||
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||
|
||||
"bcryptjs": ["[email protected]", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
|
||||
|
||||
"body-parser": ["[email protected]", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
+30
-25
@@ -1,26 +1,16 @@
|
||||
import NextAuth from 'next-auth';
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import Google from 'next-auth/providers/google';
|
||||
import GitHub from 'next-auth/providers/github';
|
||||
import Credentials from 'next-auth/providers/credentials';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
// Dummy hash for timing-safe comparison when user doesn't exist
|
||||
// This prevents user enumeration via timing attacks
|
||||
const DUMMY_HASH = '$2a$12$000000000000000000000uGG3k3xK2CVTxXrT7VW2sGd1XrY6Ky';
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
adapter: PrismaAdapter(db),
|
||||
// Note: We don't use PrismaAdapter with Credentials + JWT strategy
|
||||
// The adapter is for OAuth providers that need to store accounts/sessions in DB
|
||||
providers: [
|
||||
// Google OAuth - uncomment and add credentials when ready
|
||||
// Google({
|
||||
// clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
// clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
// }),
|
||||
|
||||
// GitHub OAuth - uncomment and add credentials when ready
|
||||
// GitHub({
|
||||
// clientId: process.env.GITHUB_ID,
|
||||
// clientSecret: process.env.GITHUB_SECRET,
|
||||
// }),
|
||||
|
||||
// Email/Password - for development, add proper provider in production
|
||||
Credentials({
|
||||
name: 'credentials',
|
||||
credentials: {
|
||||
@@ -28,28 +18,43 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
// TODO: Implement proper credential validation
|
||||
// This is a placeholder for development
|
||||
if (!credentials?.email) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// In production, verify password hash here
|
||||
const email = credentials.email as string;
|
||||
const password = credentials.password as string;
|
||||
|
||||
// Find user by email
|
||||
const user = await db.user.findUnique({
|
||||
where: { email: credentials.email as string },
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
|
||||
return user;
|
||||
// Always perform bcrypt comparison to prevent timing attacks
|
||||
// If user doesn't exist, compare against dummy hash
|
||||
const hashToCompare = user?.password || DUMMY_HASH;
|
||||
const isValidPassword = await bcrypt.compare(password, hashToCompare);
|
||||
|
||||
// Only return user if they exist AND password is valid
|
||||
if (!user || !user.password || !isValidPassword) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
// signUp: '/register',
|
||||
// error: '/auth/error',
|
||||
},
|
||||
callbacks: {
|
||||
async session({ session, token }) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
interface RateLimitConfig {
|
||||
windowMs: number; // Time window in milliseconds
|
||||
maxRequests: number; // Max requests per window
|
||||
}
|
||||
|
||||
interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetAt: Date;
|
||||
}
|
||||
|
||||
// Default configs for different actions
|
||||
export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
||||
register: { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
|
||||
login: { windowMs: 15 * 60 * 1000, maxRequests: 10 }, // 10 per 15 min
|
||||
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
|
||||
};
|
||||
|
||||
/**
|
||||
* Check and update rate limit for a given key and action
|
||||
* Uses PostgreSQL UNLOGGED table for performance
|
||||
*/
|
||||
export async function checkRateLimit(
|
||||
key: string,
|
||||
action: string,
|
||||
config?: RateLimitConfig
|
||||
): Promise<RateLimitResult> {
|
||||
const { windowMs, maxRequests } = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api;
|
||||
const windowSeconds = Math.floor(windowMs / 1000);
|
||||
|
||||
try {
|
||||
// Atomic upsert with window check
|
||||
// If window expired, reset count; otherwise increment
|
||||
const result = await db.$queryRaw<Array<{
|
||||
count: number;
|
||||
window_start: Date;
|
||||
is_new_window: boolean;
|
||||
}>>`
|
||||
INSERT INTO rate_limits (key, action, count, window_start)
|
||||
VALUES (${key}, ${action}, 1, NOW())
|
||||
ON CONFLICT (key, action) DO UPDATE SET
|
||||
count = CASE
|
||||
WHEN rate_limits.window_start < NOW() - (${windowSeconds} || ' seconds')::INTERVAL
|
||||
THEN 1
|
||||
ELSE rate_limits.count + 1
|
||||
END,
|
||||
window_start = CASE
|
||||
WHEN rate_limits.window_start < NOW() - (${windowSeconds} || ' seconds')::INTERVAL
|
||||
THEN NOW()
|
||||
ELSE rate_limits.window_start
|
||||
END
|
||||
RETURNING count, window_start,
|
||||
(window_start = NOW()) as is_new_window
|
||||
`;
|
||||
|
||||
const record = result[0];
|
||||
const resetAt = new Date(record.window_start.getTime() + windowMs);
|
||||
const remaining = Math.max(0, maxRequests - record.count);
|
||||
const allowed = record.count <= maxRequests;
|
||||
|
||||
return { allowed, remaining, resetAt };
|
||||
} catch (error) {
|
||||
// If table doesn't exist, allow the request but log warning
|
||||
console.error('Rate limit check failed (table may not exist):', error);
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: maxRequests,
|
||||
resetAt: new Date(Date.now() + windowMs),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP from request headers
|
||||
* Handles common proxy headers
|
||||
*/
|
||||
export function getClientIp(request: Request): string {
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
const realIp = request.headers.get('x-real-ip');
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
// Fallback for local development
|
||||
return '127.0.0.1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rate limit headers for response
|
||||
*/
|
||||
export function rateLimitHeaders(result: RateLimitResult, maxRequests: number): HeadersInit {
|
||||
return {
|
||||
'X-RateLimit-Limit': maxRequests.toString(),
|
||||
'X-RateLimit-Remaining': result.remaining.toString(),
|
||||
'X-RateLimit-Reset': Math.floor(result.resetAt.getTime() / 1000).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old rate limit entries (call periodically)
|
||||
*/
|
||||
export async function cleanupRateLimits(): Promise<void> {
|
||||
try {
|
||||
await db.$executeRaw`SELECT cleanup_rate_limits()`;
|
||||
} catch (error) {
|
||||
console.error('Rate limit cleanup failed:', error);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"@base-ui/react": "^1.1.0",
|
||||
"@prisma/adapter-pg": "^7.3.0",
|
||||
"@prisma/client": "^7.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -33,6 +34,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "^19",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Rate Limiting Table (UNLOGGED for performance)
|
||||
-- Run this migration manually: psql $DATABASE_URL -f prisma/migrations/rate_limit.sql
|
||||
|
||||
-- Drop if exists (for re-running)
|
||||
DROP TABLE IF EXISTS rate_limits;
|
||||
|
||||
-- Create UNLOGGED table for rate limiting
|
||||
-- UNLOGGED = no WAL writes = faster, but data lost on crash (acceptable for rate limits)
|
||||
CREATE UNLOGGED TABLE rate_limits (
|
||||
id SERIAL PRIMARY KEY,
|
||||
key VARCHAR(255) NOT NULL, -- e.g., "register:192.168.1.1" or "login:[email protected]"
|
||||
action VARCHAR(50) NOT NULL, -- e.g., "register", "login", "api"
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
window_start TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Unique constraint for upsert operations
|
||||
UNIQUE(key, action)
|
||||
);
|
||||
|
||||
-- Index for fast lookups
|
||||
CREATE INDEX idx_rate_limits_key_action ON rate_limits(key, action);
|
||||
|
||||
-- Index for cleanup operations
|
||||
CREATE INDEX idx_rate_limits_window_start ON rate_limits(window_start);
|
||||
|
||||
-- Auto-cleanup function: removes expired entries
|
||||
CREATE OR REPLACE FUNCTION cleanup_rate_limits() RETURNS void AS $$
|
||||
BEGIN
|
||||
DELETE FROM rate_limits WHERE window_start < NOW() - INTERVAL '1 hour';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -18,6 +18,7 @@ model User {
|
||||
email String? @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
password String? // Hashed password for email/password auth
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -248,7 +249,7 @@ model ShareLink {
|
||||
expiresAt DateTime? // Link expiration
|
||||
maxUses Int? // Maximum number of uses
|
||||
useCount Int @default(0)
|
||||
password String? // Optional password protection
|
||||
passwordHash String? // Bcrypt hash of optional password protection
|
||||
|
||||
// Settings
|
||||
allowGuests Boolean @default(true) // Allow comments without account
|
||||
|
||||
Reference in New Issue
Block a user