feat(01-01): configure Auth.js v5 JWT + Credentials, route protection middleware

- Created src/lib/auth.ts with NextAuth JWT strategy, 7-day rolling session, Credentials provider
- Created src/app/api/auth/[...nextauth]/route.ts with GET/POST handlers and force-dynamic
- Created middleware.ts at project root (not src/) protecting /agent/* routes
- Fixed db/index.ts: lazy Proxy singleton prevents neon() crash during Next.js build
- npm run build passes; /api/auth/[...nextauth] renders as Dynamic route
This commit is contained in:
Chandler Copeland
2026-03-19 13:33:15 -06:00
parent f46e7027a5
commit e5db79a8f0
4 changed files with 107 additions and 2 deletions

View File

@@ -0,0 +1,25 @@
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAgentRoute = req.nextUrl.pathname.startsWith("/agent");
const isLoginPage = req.nextUrl.pathname === "/agent/login";
// Protect all /agent/* routes except the login page itself
if (isAgentRoute && !isLoginPage && !isLoggedIn) {
return NextResponse.redirect(new URL("/agent/login", req.nextUrl.origin));
}
// Redirect already-authenticated users away from the login page
if (isLoginPage && isLoggedIn) {
return NextResponse.redirect(new URL("/agent/dashboard", req.nextUrl.origin));
}
});
export const config = {
matcher: [
// Run on /agent/* routes, excluding Next.js internals and static files
"/((?!_next/static|_next/image|favicon.ico).*)",
],
};

View File

@@ -0,0 +1,5 @@
import { handlers } from "@/lib/auth";
export const dynamic = "force-dynamic";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,57 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { z } from "zod";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export const { handlers, auth, signIn, signOut } = NextAuth({
session: {
strategy: "jwt",
maxAge: 7 * 24 * 60 * 60, // 7 days — rolling session per user decision
updateAge: 24 * 60 * 60, // Refresh cookie once per day (not every request)
},
pages: {
signIn: "/agent/login", // Custom login page — per user decision
},
providers: [
Credentials({
async authorize(credentials) {
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) return null;
const [user] = await db
.select()
.from(users)
.where(eq(users.email, parsed.data.email))
.limit(1);
if (!user) return null;
const passwordMatch = await bcrypt.compare(
parsed.data.password,
user.passwordHash
);
if (!passwordMatch) return null;
return { id: user.id, email: user.email };
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) token.id = user.id;
return token;
},
session({ session, token }) {
session.user.id = token.id as string;
return session;
},
},
});

View File

@@ -2,5 +2,23 @@ import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless"; import { neon } from "@neondatabase/serverless";
import * as schema from "./schema"; import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!); type DrizzleDb = ReturnType<typeof createDb>;
export const db = drizzle({ client: sql, schema });
function createDb() {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error("DATABASE_URL environment variable is not set");
}
const sql = neon(url);
return drizzle({ client: sql, schema });
}
// Lazy singleton — created on first use, not at module load time
let _db: DrizzleDb | undefined;
export const db = new Proxy({} as DrizzleDb, {
get(_target, prop: string | symbol) {
if (!_db) _db = createDb();
return (_db as unknown as Record<string | symbol, unknown>)[prop];
},
});