feat(auth): add NextAuth.js with credentials provider

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ivan
2026-02-01 06:24:07 +00:00
parent 981783babb
commit a7284925e6
6 changed files with 206 additions and 0 deletions

View File

@@ -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 };

View File

@@ -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 <SessionProvider>{children}</SessionProvider>;
}
export default AuthProvider;

121
apps/web/lib/auth.ts Normal file
View File

@@ -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;

27
apps/web/middleware.ts Normal file
View File

@@ -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*'],
};

View File

@@ -14,11 +14,14 @@
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.10.0", "@prisma/client": "^5.10.0",
"bcryptjs": "^2.4.3",
"next": "14.2.0", "next": "14.2.0",
"next-auth": "^4.24.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"@types/react": "^18.2.0", "@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0", "@types/react-dom": "^18.2.0",

35
apps/web/types/next-auth.d.ts vendored Normal file
View File

@@ -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;
}
}