feat(01-02): login page UI, logout flow, seed script, and auth unit tests

- src/app/(auth)/layout.tsx: centered auth layout for login page
- src/app/(auth)/login/page.tsx: login form with error/loading states, sign up link
- src/components/providers.tsx: SessionProvider wrapper for client-side session
- src/components/layout/header.tsx: authenticated header with Sign out button
- src/app/(dashboard)/layout.tsx: dashboard layout wrapping Header component
- src/app/(dashboard)/dashboard/page.tsx: basic dashboard page post-login
- src/app/layout.tsx: wrap root with SessionProvider via Providers component
- prisma/seed.ts: idempotent seed for Demo ISP tenant + admin + super-admin users
- package.json: add db:seed script and prisma.seed config, add tsx devDep
- src/lib/__tests__/auth.test.ts: 8 unit tests for authOptions callbacks
This commit is contained in:
kevin-asprec
2026-03-04 18:42:53 +08:00
parent 43761d94ee
commit 3c37cb1866
10 changed files with 895 additions and 4 deletions

18
src/app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "NetForge",
description: "NetForge ISP Management Platform",
};
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
{children}
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { getCurrentUser } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user) {
redirect("/login");
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Dashboard</h1>
<p className="text-gray-600">
Welcome back, {user.firstName} {user.lastName}
</p>
<div className="mt-6 p-4 bg-white rounded-lg border border-gray-200 inline-block">
<p className="text-sm text-gray-500">Signed in as</p>
<p className="font-medium text-gray-900">{user.email}</p>
{user.tenantId && (
<p className="text-sm text-gray-500 mt-1">Tenant: {user.tenantId}</p>
)}
{user.isSuperAdmin && (
<p className="text-sm text-blue-600 mt-1 font-medium">
Super Admin
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { Header } from "@/components/layout/header";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="px-6 py-6">{children}</main>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/providers";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "NetForge",
description: "ISP Management Platform",
};
export default function RootLayout({
@@ -27,7 +28,7 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<Providers>{children}</Providers>
</body>
</html>
);

View File

@@ -0,0 +1,33 @@
"use client";
import { signOut, useSession } from "next-auth/react";
export function Header() {
const { data: session } = useSession();
if (!session?.user) {
return null;
}
const { firstName, lastName, email } = session.user;
const displayName =
firstName && lastName ? `${firstName} ${lastName}` : email;
return (
<header className="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-gray-900">NetForge</span>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{displayName}</span>
<button
onClick={() => signOut({ callbackUrl: "/login" })}
className="text-sm text-gray-500 hover:text-gray-700 px-3 py-1.5 rounded-md hover:bg-gray-100 transition-colors"
>
Sign out
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,7 @@
"use client";
import { SessionProvider } from "next-auth/react";
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

@@ -0,0 +1,180 @@
/**
* Unit tests for NextAuth configuration (auth-options.ts).
*
* These tests verify that the authOptions object is correctly structured
* and that the JWT/session callbacks properly propagate tenantId and roles.
* They do NOT require a running server or database.
*/
import { authOptions } from "@/lib/auth-options";
import type { JWT } from "next-auth/jwt";
import type { Session } from "next-auth";
import type { Role } from "@prisma/client";
describe("authOptions", () => {
describe("configuration", () => {
it("should have credentials provider configured", () => {
expect(authOptions.providers).toBeDefined();
expect(authOptions.providers.length).toBeGreaterThan(0);
const credentialsProvider = authOptions.providers.find(
(p) => p.id === "credentials"
);
expect(credentialsProvider).toBeDefined();
});
it("should use JWT session strategy", () => {
expect(authOptions.session?.strategy).toBe("jwt");
});
it("should set session maxAge to 24 hours", () => {
expect(authOptions.session?.maxAge).toBe(24 * 60 * 60);
});
it("should have signIn page set to /login", () => {
expect(authOptions.pages?.signIn).toBe("/login");
});
});
describe("JWT callback", () => {
it("should persist user fields into the JWT token on initial sign-in", async () => {
const jwtCallback = authOptions.callbacks?.jwt;
expect(jwtCallback).toBeDefined();
if (!jwtCallback) return;
const mockUser = {
id: "user-123",
email: "admin@demo.com",
tenantId: "tenant-abc",
roles: ["ADMIN"] as Role[],
isSuperAdmin: false,
firstName: "Demo",
lastName: "Admin",
};
const mockToken = { sub: "user-123" } as JWT;
// Cast through unknown to avoid strict NextAuth type checking in tests
const result = await jwtCallback({
token: mockToken,
user: mockUser as unknown as Parameters<typeof jwtCallback>[0]["user"],
account: null,
trigger: "signIn",
});
expect(result.id).toBe("user-123");
expect(result.email).toBe("admin@demo.com");
expect(result.tenantId).toBe("tenant-abc");
expect(result.roles).toEqual(["ADMIN"]);
expect(result.isSuperAdmin).toBe(false);
expect(result.firstName).toBe("Demo");
expect(result.lastName).toBe("Admin");
});
it("should preserve existing token fields when user is not present (token refresh)", async () => {
const jwtCallback = authOptions.callbacks?.jwt;
if (!jwtCallback) return;
const existingToken = {
sub: "user-123",
id: "user-123",
email: "admin@demo.com",
tenantId: "tenant-abc",
roles: ["ADMIN"] as Role[],
isSuperAdmin: false,
firstName: "Demo",
lastName: "Admin",
} as JWT;
const result = await jwtCallback({
token: existingToken,
user: null as unknown as Parameters<typeof jwtCallback>[0]["user"],
account: null,
trigger: "update",
});
expect(result.tenantId).toBe("tenant-abc");
expect(result.roles).toEqual(["ADMIN"]);
});
it("should handle super-admin with null tenantId", async () => {
const jwtCallback = authOptions.callbacks?.jwt;
if (!jwtCallback) return;
const superAdminUser = {
id: "superadmin-1",
email: "superadmin@netforge.com",
tenantId: null,
roles: [] as Role[],
isSuperAdmin: true,
firstName: "Super",
lastName: "Admin",
};
const result = await jwtCallback({
token: {} as JWT,
user: superAdminUser as unknown as Parameters<
typeof jwtCallback
>[0]["user"],
account: null,
trigger: "signIn",
});
expect(result.tenantId).toBeNull();
expect(result.isSuperAdmin).toBe(true);
});
});
describe("session callback", () => {
it("should expose JWT fields on session.user", async () => {
const sessionCallback = authOptions.callbacks?.session;
expect(sessionCallback).toBeDefined();
if (!sessionCallback) return;
const mockToken = {
sub: "user-123",
id: "user-123",
email: "admin@demo.com",
tenantId: "tenant-abc",
roles: ["ADMIN"] as Role[],
isSuperAdmin: false,
firstName: "Demo",
lastName: "Admin",
} as JWT;
const mockSession: Session = {
user: {
id: "",
email: "",
tenantId: null,
roles: [],
isSuperAdmin: false,
firstName: "",
lastName: "",
},
expires: new Date(Date.now() + 86400000).toISOString(),
};
const result = await sessionCallback({
session: mockSession,
token: mockToken,
user: undefined as unknown as Parameters<
typeof sessionCallback
>[0]["user"],
newSession: undefined,
trigger: "update",
});
// Type the result session.user through our extended Session type
const user = result.user as Session["user"];
expect(user.id).toBe("user-123");
expect(user.email).toBe("admin@demo.com");
expect(user.tenantId).toBe("tenant-abc");
expect(user.roles).toEqual(["ADMIN"]);
expect(user.isSuperAdmin).toBe(false);
expect(user.firstName).toBe("Demo");
expect(user.lastName).toBe("Admin");
});
});
});