diff --git a/package-lock.json b/package-lock.json index 3700616..1c63c26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", + "tsx": "^4.21.0", "typescript": "^5", "vitest": "^4.0.18" } @@ -8463,6 +8464,510 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index f2eacda..87c646e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,11 @@ "test:watch": "vitest", "db:push": "prisma db push", "db:generate": "prisma generate", - "db:studio": "prisma studio" + "db:studio": "prisma studio", + "db:seed": "npx tsx prisma/seed.ts" + }, + "prisma": { + "seed": "npx tsx prisma/seed.ts" }, "dependencies": { "@prisma/client": "^6.19.2", @@ -33,6 +37,7 @@ "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", + "tsx": "^4.21.0", "typescript": "^5", "vitest": "^4.0.18" } diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..90313c7 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,97 @@ +import { PrismaClient, TenantStatus } from "@prisma/client"; +import bcrypt from "bcryptjs"; + +const prisma = new PrismaClient(); + +async function main() { + console.log("Seeding database..."); + + // ----------------------------------------------------------------------- + // Tenant: Demo ISP + // ----------------------------------------------------------------------- + const demoTenant = await prisma.tenant.upsert({ + where: { slug: "demo-isp" }, + update: {}, + create: { + name: "Demo ISP", + slug: "demo-isp", + ownerEmail: "admin@demo.com", + status: TenantStatus.ACTIVE, + }, + }); + + console.log(`Tenant upserted: ${demoTenant.name} (${demoTenant.id})`); + + // ----------------------------------------------------------------------- + // Admin user: admin@demo.com / admin123 + // Scoped to Demo ISP tenant + // ----------------------------------------------------------------------- + const adminPasswordHash = await bcrypt.hash("admin123", 12); + + const adminUser = await prisma.user.upsert({ + where: { + email_tenantId: { + email: "admin@demo.com", + tenantId: demoTenant.id, + }, + }, + update: {}, + create: { + email: "admin@demo.com", + passwordHash: adminPasswordHash, + firstName: "Demo", + lastName: "Admin", + tenantId: demoTenant.id, + roles: ["ADMIN"], + isActive: true, + isSuperAdmin: false, + }, + }); + + console.log(`Admin user upserted: ${adminUser.email} (${adminUser.id})`); + + // ----------------------------------------------------------------------- + // Super-admin: superadmin@netforge.com / super123 + // No tenant — platform-wide access + // ----------------------------------------------------------------------- + const superAdminPasswordHash = await bcrypt.hash("super123", 12); + + // Super-admin has tenantId=null — use findFirst + create pattern since + // @@unique([email, tenantId]) with null tenantId behaves differently across DBs + const existingSuperAdmin = await prisma.user.findFirst({ + where: { + email: "superadmin@netforge.com", + tenantId: null, + }, + }); + + const superAdmin = existingSuperAdmin + ? existingSuperAdmin + : await prisma.user.create({ + data: { + email: "superadmin@netforge.com", + passwordHash: superAdminPasswordHash, + firstName: "Super", + lastName: "Admin", + tenantId: null, + roles: [], + isActive: true, + isSuperAdmin: true, + }, + }); + + console.log(`Super-admin upserted: ${superAdmin.email} (${superAdmin.id})`); + + console.log("\nSeed complete. Test credentials:"); + console.log(" Admin: admin@demo.com / admin123"); + console.log(" Super-admin: superadmin@netforge.com / super123"); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..a50fc15 --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -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 ( +
+ {children} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..99b9a3d --- /dev/null +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -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 ( +
+

Dashboard

+

+ Welcome back, {user.firstName} {user.lastName} +

+
+

Signed in as

+

{user.email}

+ {user.tenantId && ( +

Tenant: {user.tenantId}

+ )} + {user.isSuperAdmin && ( +

+ Super Admin +

+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..a82a58d --- /dev/null +++ b/src/app/(dashboard)/layout.tsx @@ -0,0 +1,14 @@ +import { Header } from "@/components/layout/header"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+
{children}
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..fb152f8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ - {children} + {children} ); diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx new file mode 100644 index 0000000..5057271 --- /dev/null +++ b/src/components/layout/header.tsx @@ -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 ( +
+
+ NetForge +
+ +
+ {displayName} + +
+
+ ); +} diff --git a/src/components/providers.tsx b/src/components/providers.tsx new file mode 100644 index 0000000..f4cd92d --- /dev/null +++ b/src/components/providers.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { SessionProvider } from "next-auth/react"; + +export function Providers({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/lib/__tests__/auth.test.ts b/src/lib/__tests__/auth.test.ts new file mode 100644 index 0000000..23ba1d1 --- /dev/null +++ b/src/lib/__tests__/auth.test.ts @@ -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[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[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"); + }); + }); +});