feat(01-02): configure NextAuth.js v4 with credentials provider and JWT

- Install next-auth@4, bcryptjs, @types/bcryptjs, @types/jest
- src/types/next-auth.d.ts: extend Session/JWT with tenantId, roles, isSuperAdmin
- src/lib/auth-options.ts: CredentialsProvider + JWT/session callbacks, 24h maxAge
- src/lib/auth.ts: getServerSession() and getCurrentUser() server helpers
- src/app/api/auth/[...nextauth]/route.ts: NextAuth GET/POST handler
- src/middleware.ts: withAuth middleware protecting all routes except /login /signup /api/auth/*
This commit is contained in:
kevin-asprec
2026-03-04 18:37:29 +08:00
parent 19a5520dd5
commit 71a9277913
7 changed files with 857 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth-options";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

100
src/lib/auth-options.ts Normal file
View File

@@ -0,0 +1,100 @@
import { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { TenantStatus } from "@prisma/client";
export const authOptions: NextAuthOptions = {
session: {
strategy: "jwt",
maxAge: 24 * 60 * 60, // 24 hours
},
pages: {
signIn: "/login",
},
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findFirst({
where: {
email: credentials.email,
isActive: true,
OR: [
{ isSuperAdmin: true },
{
tenant: {
status: TenantStatus.ACTIVE,
},
},
],
},
include: {
tenant: {
select: { status: true },
},
},
});
if (!user) {
return null;
}
const passwordValid = await bcrypt.compare(
credentials.password,
user.passwordHash
);
if (!passwordValid) {
return null;
}
return {
id: user.id,
email: user.email,
tenantId: user.tenantId,
roles: user.roles,
isSuperAdmin: user.isSuperAdmin,
firstName: user.firstName,
lastName: user.lastName,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
// On initial sign-in, user is populated — persist fields into token
if (user) {
token.id = user.id;
token.email = user.email;
token.tenantId = user.tenantId;
token.roles = user.roles;
token.isSuperAdmin = user.isSuperAdmin;
token.firstName = user.firstName;
token.lastName = user.lastName;
}
return token;
},
async session({ session, token }) {
// Expose token fields on session.user for client-side access
session.user = {
id: token.id,
email: token.email,
tenantId: token.tenantId,
roles: token.roles,
isSuperAdmin: token.isSuperAdmin,
firstName: token.firstName,
lastName: token.lastName,
};
return session;
},
},
};

26
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,26 @@
import { getServerSession as nextAuthGetServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
/**
* Server-side helper to get the current session.
* Wraps next-auth's getServerSession with the app's authOptions.
*
* Usage (in Server Components, Route Handlers, Server Actions):
* const session = await getServerSession();
*/
export async function getServerSession() {
return nextAuthGetServerSession(authOptions);
}
/**
* Returns the typed session user or null if not authenticated.
* Convenience wrapper that extracts session.user.
*
* Usage:
* const user = await getCurrentUser();
* if (!user) redirect("/login");
*/
export async function getCurrentUser() {
const session = await getServerSession();
return session?.user ?? null;
}

36
src/middleware.ts Normal file
View File

@@ -0,0 +1,36 @@
import { withAuth } from "next-auth/middleware";
import { NextResponse } from "next/server";
export default withAuth(
function middleware(req) {
// If user is authenticated, allow request through
return NextResponse.next();
},
{
callbacks: {
authorized({ token }) {
// Return true if token exists (user is authenticated)
return !!token;
},
},
pages: {
signIn: "/login",
},
}
);
// Apply middleware to all routes EXCEPT public ones
export const config = {
matcher: [
/*
* Match all request paths EXCEPT:
* - /login (sign-in page)
* - /signup (registration page)
* - /api/auth/* (NextAuth endpoints)
* - /_next/* (Next.js internals)
* - /favicon.ico, /robots.txt, /sitemap.xml (static files)
* - Image files (.png, .jpg, .jpeg, .gif, .webp, .svg, .ico)
*/
"/((?!login|signup|api/auth|_next/static|_next/image|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico)).*)",
],
};

39
src/types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,39 @@
import { Role } from "@prisma/client";
import "next-auth";
import "next-auth/jwt";
declare module "next-auth" {
interface Session {
user: {
id: string;
email: string;
tenantId: string | null;
roles: Role[];
isSuperAdmin: boolean;
firstName: string;
lastName: string;
};
}
interface User {
id: string;
email: string;
tenantId: string | null;
roles: Role[];
isSuperAdmin: boolean;
firstName: string;
lastName: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string;
email: string;
tenantId: string | null;
roles: Role[];
isSuperAdmin: boolean;
firstName: string;
lastName: string;
}
}