@@ -55,98 +150,9 @@ export default function LoginPage() {
OpenFrame
-
-
- Welcome back
-
- Sign in to your account to continue
-
-
-
- {/* OAuth Buttons */}
-
-
handleOAuthLogin('google')}
- disabled={isLoading}
- >
-
-
-
-
-
-
- Continue with Google
-
-
handleOAuthLogin('github')}
- disabled={isLoading}
- >
-
- Continue with GitHub
-
-
-
-
-
-
- or continue with email
-
-
-
- {/* Email Form */}
-
-
-
- Don't have an account?{' '}
-
- Sign up
-
-
-
-
+
}>
+
+
By continuing, you agree to our Terms of Service and Privacy Policy
diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx
new file mode 100644
index 0000000..981e57d
--- /dev/null
+++ b/app/(auth)/register/page.tsx
@@ -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) => {
+ 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 (
+
+
+ {/* Logo */}
+
+
+
OpenFrame
+
+
+
+
+
+
+ Create Account
+
+
+ Join OpenFrame to collaborate on video projects
+
+
+
+
+
+
+ Already have an account?{' '}
+
+ Sign in
+
+
+
+
+
+
+ By continuing, you agree to our Terms of Service and Privacy Policy
+
+
+
+ );
+}
diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts
new file mode 100644
index 0000000..04860e5
--- /dev/null
+++ b/app/api/auth/register/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/app/page.tsx b/app/page.tsx
index 6ebdad2..52da58b 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -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 (
{/* Header */}
@@ -13,12 +17,25 @@ export default function HomePage() {
OpenFrame
-
- Sign in
-
-
- Get Started
-
+ {isLoggedIn ? (
+ <>
+
+ Dashboard
+
+
+ New Project
+
+ >
+ ) : (
+ <>
+
+ Sign in
+
+
+ Get Started
+
+ >
+ )}
@@ -31,16 +48,25 @@ export default function HomePage() {
reimagined
- 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.
-
-
- Start for free
-
-
-
+ {isLoggedIn ? (
+
+
+ Go to Dashboard
+
+
+
+ ) : (
+
+
+ Start for free
+
+
+
+ )}
@@ -88,8 +114,8 @@ export default function HomePage() {
Join teams who have already switched to OpenFrame for faster, clearer video feedback.
-
- Get started for free
+
+ {isLoggedIn ? 'Go to Dashboard' : 'Get started for free'}
@@ -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;
diff --git a/bun.lock b/bun.lock
index 6e867f0..a5f7d8d 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+ "@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
+
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -623,6 +627,8 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
+ "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
+
"body-parser": ["body-parser@2.2.2", "", { "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": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
diff --git a/lib/auth.ts b/lib/auth.ts
index 2ca2a5c..7741357 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -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 }) {
diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts
new file mode 100644
index 0000000..67a4624
--- /dev/null
+++ b/lib/rate-limit.ts
@@ -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 = {
+ 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 {
+ 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>`
+ 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 {
+ try {
+ await db.$executeRaw`SELECT cleanup_rate_limits()`;
+ } catch (error) {
+ console.error('Rate limit cleanup failed:', error);
+ }
+}
diff --git a/package.json b/package.json
index afaef4a..64523b0 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/prisma/migrations/rate_limit.sql b/prisma/migrations/rate_limit.sql
new file mode 100644
index 0000000..5c7fb94
--- /dev/null
+++ b/prisma/migrations/rate_limit.sql
@@ -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:user@example.com"
+ 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;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 26d67f1..df3e0ea 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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