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

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