From a7284925e663c50893802f0f51982156bc948ef9 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 1 Feb 2026 06:24:07 +0000 Subject: [PATCH] feat(auth): add NextAuth.js with credentials provider Co-Authored-By: Claude Opus 4.5 --- apps/web/app/api/auth/[...nextauth]/route.ts | 6 + .../components/providers/auth-provider.tsx | 14 ++ apps/web/lib/auth.ts | 121 ++++++++++++++++++ apps/web/middleware.ts | 27 ++++ apps/web/package.json | 3 + apps/web/types/next-auth.d.ts | 35 +++++ 6 files changed, 206 insertions(+) create mode 100644 apps/web/app/api/auth/[...nextauth]/route.ts create mode 100644 apps/web/components/providers/auth-provider.tsx create mode 100644 apps/web/lib/auth.ts create mode 100644 apps/web/middleware.ts create mode 100644 apps/web/types/next-auth.d.ts diff --git a/apps/web/app/api/auth/[...nextauth]/route.ts b/apps/web/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..0a4c217 --- /dev/null +++ b/apps/web/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from 'next-auth'; +import { authOptions } from '@/lib/auth'; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/apps/web/components/providers/auth-provider.tsx b/apps/web/components/providers/auth-provider.tsx new file mode 100644 index 0000000..3b481c2 --- /dev/null +++ b/apps/web/components/providers/auth-provider.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SessionProvider } from 'next-auth/react'; +import { ReactNode } from 'react'; + +interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + return {children}; +} + +export default AuthProvider; diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts new file mode 100644 index 0000000..df133ba --- /dev/null +++ b/apps/web/lib/auth.ts @@ -0,0 +1,121 @@ +import { NextAuthOptions } from 'next-auth'; +import CredentialsProvider from 'next-auth/providers/credentials'; +import { compare } from 'bcryptjs'; +import { db } from '@/lib/db'; + +export const authOptions: NextAuthOptions = { + session: { + strategy: 'jwt', + maxAge: 30 * 24 * 60 * 60, // 30 days + }, + pages: { + signIn: '/login', + error: '/login', + }, + providers: [ + CredentialsProvider({ + 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 db.user.findFirst({ + where: { + email: credentials.email, + isActive: true, + }, + include: { + organization: { + select: { + id: true, + name: true, + }, + }, + sites: { + where: { + isActive: true, + }, + select: { + id: true, + name: true, + }, + take: 1, + }, + }, + }); + + if (!user) { + throw new Error('Invalid email or password'); + } + + const isPasswordValid = await compare(credentials.password, user.password); + + if (!isPasswordValid) { + throw new Error('Invalid email or password'); + } + + // Update last login + await db.user.update({ + where: { id: user.id }, + data: { lastLogin: new Date() }, + }); + + // Get the first site if available + const primarySite = user.sites[0]; + + return { + id: user.id, + email: user.email, + name: `${user.firstName} ${user.lastName}`, + role: user.role, + organizationId: user.organizationId, + organizationName: user.organization.name, + siteId: primarySite?.id ?? null, + siteName: primarySite?.name ?? null, + }; + }, + }), + ], + callbacks: { + async jwt({ token, user, trigger, session }) { + if (user) { + token.id = user.id; + token.role = user.role; + token.organizationId = user.organizationId; + token.organizationName = user.organizationName; + token.siteId = user.siteId; + token.siteName = user.siteName; + } + + // Handle session update (e.g., when user changes site) + if (trigger === 'update' && session) { + if (session.siteId !== undefined) { + token.siteId = session.siteId; + } + if (session.siteName !== undefined) { + token.siteName = session.siteName; + } + } + + return token; + }, + async session({ session, token }) { + if (token) { + session.user.id = token.id as string; + session.user.role = token.role as string; + session.user.organizationId = token.organizationId as string; + session.user.organizationName = token.organizationName as string; + session.user.siteId = token.siteId as string | null; + session.user.siteName = token.siteName as string | null; + } + return session; + }, + }, +}; + +export default authOptions; diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts new file mode 100644 index 0000000..6ce85eb --- /dev/null +++ b/apps/web/middleware.ts @@ -0,0 +1,27 @@ +import { withAuth } from 'next-auth/middleware'; +import { NextResponse } from 'next/server'; + +export default withAuth( + function middleware(req) { + const token = req.nextauth.token; + const pathname = req.nextUrl.pathname; + + // Check for SUPER_ADMIN only routes + if (pathname.startsWith('/admin/settings')) { + if (token?.role !== 'SUPER_ADMIN') { + return NextResponse.redirect(new URL('/admin', req.url)); + } + } + + return NextResponse.next(); + }, + { + callbacks: { + authorized: ({ token }) => !!token, + }, + } +); + +export const config = { + matcher: ['/admin/:path*'], +}; diff --git a/apps/web/package.json b/apps/web/package.json index ba70029..fe9ffd1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,11 +14,14 @@ }, "dependencies": { "@prisma/client": "^5.10.0", + "bcryptjs": "^2.4.3", "next": "14.2.0", + "next-auth": "^4.24.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/node": "^20.11.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", diff --git a/apps/web/types/next-auth.d.ts b/apps/web/types/next-auth.d.ts new file mode 100644 index 0000000..f8db07a --- /dev/null +++ b/apps/web/types/next-auth.d.ts @@ -0,0 +1,35 @@ +import 'next-auth'; +import { DefaultSession } from 'next-auth'; + +declare module 'next-auth' { + interface Session { + user: { + id: string; + role: string; + organizationId: string; + organizationName: string; + siteId: string | null; + siteName: string | null; + } & DefaultSession['user']; + } + + interface User { + id: string; + role: string; + organizationId: string; + organizationName: string; + siteId: string | null; + siteName: string | null; + } +} + +declare module 'next-auth/jwt' { + interface JWT { + id: string; + role: string; + organizationId: string; + organizationName: string; + siteId: string | null; + siteName: string | null; + } +}