diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 00000000..aab004ce --- /dev/null +++ b/middleware.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { getToken } from 'next-auth/jwt'; + +const publicPaths = ['/', '/login', '/signup', '/api/auth/signin', '/api/auth/signout']; + +export async function middleware(req: NextRequest) { + const { pathname } = req.nextUrl; + const token = await getToken({ req }); + + // Allow access to public paths and static files + if ( + publicPaths.some(path => path === pathname) || + pathname.startsWith('/_next') || + pathname.startsWith('/favicon.ico') || + pathname.startsWith('/public/') + ) { + // If user is logged in and tries to access auth pages, redirect to dashboard + if (token && (pathname.startsWith('/login') || pathname.startsWith('/signup'))) { + return NextResponse.redirect(new URL('/dashboard', req.url)); + } + return NextResponse.next(); + } + + // If no token and not a public path, redirect to login + if (!token) { + const loginUrl = new URL('/login', req.url); + loginUrl.searchParams.set('callbackUrl', pathname); + return NextResponse.redirect(loginUrl); + } + + return NextResponse.next(); +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - public folder + */ + '/((?!api|_next/static|_next/image|favicon.ico|public).*)', + ], +}; diff --git a/prisma/migrations/20250726215047_init/migration.sql b/prisma/migrations/20250726215047_init/migration.sql new file mode 100644 index 00000000..d8ed94c4 --- /dev/null +++ b/prisma/migrations/20250726215047_init/migration.sql @@ -0,0 +1,57 @@ +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT, + "email" TEXT, + "email_verified" DATETIME, + "image" TEXT, + "password" TEXT +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionToken" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expires" DATETIME NOT NULL, + CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "VerificationToken" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..2a5a4441 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d58fc4ec..d26a36c7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,8 +7,8 @@ generator client { } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "sqlite" + url = "file:./dev.db" } model Account { @@ -17,12 +17,12 @@ model Account { type String provider String providerAccountId String - refresh_token String? @db.Text - access_token String? @db.Text + refresh_token String? + access_token String? expires_at Int? token_type String? scope String? - id_token String? @db.Text + id_token String? session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index 95cc195e..dac21021 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,28 @@ -import NextAuth from "next-auth"; -import { authOptions } from "@/lib/auth"; +import NextAuth from 'next-auth'; +import { authConfig } from '@/auth'; -const handler = NextAuth(authOptions); +// Initialize NextAuth with the configuration +export const { + handlers: { GET, POST }, + auth, + signIn, + signOut, +} = NextAuth({ + ...authConfig, + // Enable debug logs in development + debug: process.env.NODE_ENV === 'development', + // Ensure cookies are secure in production + cookies: { + sessionToken: { + name: `__Secure-next-auth.session-token`, + options: { + httpOnly: true, + sameSite: 'lax', // CSRF protection + path: '/', + secure: process.env.NODE_ENV === 'production', + }, + }, + }, +}); -export { handler as GET, handler as POST }; \ No newline at end of file +export { GET, POST, auth, signIn, signOut }; \ No newline at end of file diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index b4f6ec36..37838603 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,6 +1,7 @@ 'use client'; -import { signOut, useSession } from 'next-auth/react'; +import { signOut } from '@/app/api/auth/[...nextauth]/route'; +import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; import { useEffect } from 'react'; @@ -22,8 +23,9 @@ export default function DashboardPage() { ); } - if (!session) { - return null; // Will be redirected by the useEffect + if (status === 'unauthenticated') { + router.push('/login'); + return null; } return ( @@ -34,9 +36,12 @@ export default function DashboardPage() {

Dashboard

-
+
+ + Welcome, {session.user?.name || 'User'}! + +
+ +
+ +
+
+ - {error && ( -
+ {success && ( +
- - + + + +
+
+

{success}

+
+
+
+ )} + {error && ( +
+
+
+ +
diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 092b77ab..1b0db99f 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -1,7 +1,9 @@ 'use client'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { signIn } from 'next-auth/react'; export default function SignupPage() { const [name, setName] = useState(''); @@ -11,28 +13,81 @@ export default function SignupPage() { const [isLoading, setIsLoading] = useState(false); const router = useRouter(); + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get('callbackUrl') || '/dashboard'; + + // Handle any error from the URL (e.g., from auth callback) + useEffect(() => { + const errorParam = searchParams.get('error'); + if (errorParam) { + // Remove the error from the URL + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.delete('error'); + router.replace(`/signup?${newSearchParams.toString()}`); + + // Set appropriate error message + let errorMessage = 'An error occurred during signup'; + if (errorParam === 'OAuthAccountNotLinked') { + errorMessage = 'This email is already associated with another account'; + } else if (errorParam === 'OAuthCallbackError') { + errorMessage = 'An error occurred during social sign in'; + } + setError(errorMessage); + } + }, [router, searchParams]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setIsLoading(true); try { + // Basic validation + if (!name || !email || !password) { + throw new Error('All fields are required'); + } + + if (password.length < 8) { + throw new Error('Password must be at least 8 characters long'); + } + + // Create user in the database const response = await fetch('/api/auth/signup', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ name, email, password }), + body: JSON.stringify({ + name, + email, + password // The password will be hashed in the API route + }), }); const data = await response.json(); if (!response.ok) { - throw new Error(data.message || 'Something went wrong'); + throw new Error(data.message || 'Something went wrong during signup'); } - // Redirect to login page after successful signup - router.push('/login'); + // Automatically sign in the user after successful signup + const signInResult = await signIn('credentials', { + redirect: false, + email, + password, + callbackUrl, + }); + + if (signInResult?.error) { + // If sign in fails, redirect to login page with a success message + router.push(`/login?registered=true&callbackUrl=${encodeURIComponent(callbackUrl)}`); + } else if (signInResult?.url) { + // If sign in is successful, redirect to the callback URL or dashboard + router.push(signInResult.url); + } else { + // Fallback redirect + router.push(callbackUrl); + } } catch (err) { setError(err instanceof Error ? err.message : 'An error occurred'); setIsLoading(false); @@ -47,14 +102,51 @@ export default function SignupPage() { Create a new account

- Or{' '} - - sign in to your existing account - + Sign in +

+ +
+
+
+
+
+
+ Or continue with +
+
+ +
+
+ +
+ +
+ +
+
+
{error && (
diff --git a/src/auth.config.ts b/src/auth.config.ts new file mode 100644 index 00000000..7baaf46a --- /dev/null +++ b/src/auth.config.ts @@ -0,0 +1,67 @@ +import type { NextAuthConfig } from "next-auth"; +import Credentials from "next-auth/providers/credentials"; +import { prisma } from "./lib/prisma"; +import { compare } from "bcryptjs"; + +export default { + providers: [ + Credentials({ + name: "Credentials", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + if (!credentials?.email || !credentials?.password) { + throw new Error("Email and password are required"); + } + + const user = await prisma.user.findUnique({ + where: { email: credentials.email as string }, + }); + + if (!user || !user.password) { + throw new Error("Invalid email or password"); + } + + const isValid = await compare( + credentials.password as string, + user.password + ); + + if (!isValid) { + throw new Error("Invalid email or password"); + } + + return { + id: user.id, + email: user.email, + name: user.name, + image: user.image, + }; + }, + }), + ], + callbacks: { + async session({ session, token }) { + if (token && session.user) { + session.user.id = token.sub!; + } + return session; + }, + async jwt({ token, user }) { + if (user) { + token.sub = user.id; + } + return token; + }, + }, + session: { + strategy: "jwt", + }, + pages: { + signIn: "/login", + error: "/login", + }, + secret: process.env.NEXTAUTH_SECRET, +} satisfies NextAuthConfig; diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 00000000..c04b7c06 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,83 @@ +import type { NextAuthConfig } from 'next-auth'; +import Credentials from 'next-auth/providers/credentials'; +import { prisma } from './lib/prisma'; +import { compare } from 'bcryptjs'; + +export const authConfig = { + pages: { + signIn: '/login', + error: '/login', + }, + callbacks: { + authorized({ auth, request: { nextUrl } }) { + const isLoggedIn = !!auth?.user; + const isOnDashboard = nextUrl.pathname.startsWith('/dashboard'); + + if (isOnDashboard) { + return isLoggedIn; + } else if (isLoggedIn) { + return Response.redirect(new URL('/dashboard', nextUrl)); + } + return true; + }, + async jwt({ token, user }) { + if (user) { + token.id = user.id; + token.email = user.email; + token.name = user.name; + } + return token; + }, + async session({ session, token }) { + if (session.user) { + session.user.id = token.id as string; + session.user.name = token.name; + session.user.email = token.email as string; + } + return session; + }, + }, + providers: [ + Credentials({ + name: 'Credentials', + credentials: { + email: { label: 'Email', type: 'email' }, + password: { label: 'Password', type: 'password' }, + }, + async authorize(credentials) { + if (!credentials?.email || !credentials?.password) { + throw new Error('Email and password are required'); + } + + const user = await prisma.user.findUnique({ + where: { email: credentials.email as string }, + }); + + if (!user || !user.password) { + throw new Error('Invalid email or password'); + } + + const isValid = await compare( + credentials.password as string, + user.password + ); + + if (!isValid) { + throw new Error('Invalid email or password'); + } + + return { + id: user.id, + email: user.email, + name: user.name, + image: user.image, + }; + }, + }), + ], + session: { + strategy: 'jwt', + }, + secret: process.env.NEXTAUTH_SECRET, + trustHost: true, +} satisfies NextAuthConfig; diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 5c11b3c1..6e72cfdb 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,14 +1,16 @@ -import { PrismaClient } from '@prisma/client'; +import { PrismaClient } from '@/generated/prisma'; declare global { // eslint-disable-next-line no-var var prisma: PrismaClient | undefined; } -export const prisma = global.prisma || new PrismaClient(); +const globalForPrisma = global as unknown as { prisma: PrismaClient }; + +export const prisma = globalForPrisma.prisma || new PrismaClient(); if (process.env.NODE_ENV !== 'production') { - global.prisma = prisma; + globalForPrisma.prisma = prisma; } async function connectDB() {