From 5b436fff2d5622565fcb5191385efc57c42c207a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Sat, 7 Feb 2026 06:43:09 +0300 Subject: [PATCH] feat: Implement secure email/password authentication with user registration, API rate limiting, and dynamic homepage navigation. --- app/(auth)/layout.tsx | 12 +- app/(auth)/login/page.tsx | 238 ++++++++++++++++--------------- app/(auth)/register/page.tsx | 208 +++++++++++++++++++++++++++ app/api/auth/register/route.ts | 132 +++++++++++++++++ app/page.tsx | 68 ++++++--- bun.lock | 6 + lib/auth.ts | 59 ++++---- lib/rate-limit.ts | 114 +++++++++++++++ package.json | 2 + prisma/migrations/rate_limit.sql | 31 ++++ prisma/schema.prisma | 3 +- 11 files changed, 707 insertions(+), 166 deletions(-) create mode 100644 app/(auth)/register/page.tsx create mode 100644 app/api/auth/register/route.ts create mode 100644 lib/rate-limit.ts create mode 100644 prisma/migrations/rate_limit.sql diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx index ff87ae0..4d74432 100644 --- a/app/(auth)/layout.tsx +++ b/app/(auth)/layout.tsx @@ -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}; } diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index e82d823..dbee0bc 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -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 ( + + + Welcome back + + Sign in to your account to continue + + + + {showSuccess && ( +
+ Account created successfully! Please sign in. +
+ )} + {/* Email Form */} +
+
+ + { + setEmail(e.target.value); + setError(''); + }} + required + disabled={isLoading} + /> +
+
+ + { + setPassword(e.target.value); + setError(''); + }} + required + disabled={isLoading} + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Don't have an account?{' '} + + Sign up + +

+
+
+ ); +} + +function LoginFormSkeleton() { + return ( + + + Welcome back + + Sign in to your account to continue + + + +
+
+
+ + + ); +} + +export default function LoginPage() { return (
@@ -55,98 +150,9 @@ export default function LoginPage() { OpenFrame - - - Welcome back - - Sign in to your account to continue - - - - {/* OAuth Buttons */} -
- - -
- -
- - - or continue with email - -
- - {/* Email Form */} -
-
- - setEmail(e.target.value)} - required - disabled={isLoading} - /> -
-
- - setPassword(e.target.value)} - required - disabled={isLoading} - /> -
- -
- -

- 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 */} + +
+ ); +} 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
- - + {isLoggedIn ? ( + <> + + + + ) : ( + <> + + + + )}
@@ -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.

- + {isLoggedIn ? ( + + ) : ( + + )} @@ -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