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:
18
src/app/(auth)/layout.tsx
Normal file
18
src/app/(auth)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/app/(dashboard)/dashboard/page.tsx
Normal file
31
src/app/(dashboard)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/app/(dashboard)/layout.tsx
Normal file
14
src/app/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
33
src/components/layout/header.tsx
Normal file
33
src/components/layout/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/components/providers.tsx
Normal file
7
src/components/providers.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
180
src/lib/__tests__/auth.test.ts
Normal file
180
src/lib/__tests__/auth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user