feat(01-05): super-admin API routes and middleware guard

- withSuperAdmin() HOF: checks isSuperAdmin from session, returns 401/403
- GET /api/admin/tenants: lists all tenants with userCount, subscriberCount
- GET /api/admin/tenants/[id]: single tenant detail with users list
- POST /api/admin/tenants/[id]/suspend: suspend/activate with 7-day grace
- prisma/seed.ts: add Test ISP 2 tenant and admin2@demo.com for isolation tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-04 19:04:10 +08:00
parent d25885aaeb
commit df40eae328
5 changed files with 342 additions and 0 deletions

View File

@@ -82,8 +82,51 @@ async function main() {
console.log(`Super-admin upserted: ${superAdmin.email} (${superAdmin.id})`); console.log(`Super-admin upserted: ${superAdmin.email} (${superAdmin.id})`);
// -----------------------------------------------------------------------
// Tenant: Test ISP 2 (for cross-tenant isolation testing)
// -----------------------------------------------------------------------
const demoTenant2 = await prisma.tenant.upsert({
where: { slug: "test-isp-2" },
update: {},
create: {
name: "Test ISP 2",
slug: "test-isp-2",
ownerEmail: "admin2@demo.com",
status: TenantStatus.ACTIVE,
},
});
console.log(`Tenant upserted: ${demoTenant2.name} (${demoTenant2.id})`);
// -----------------------------------------------------------------------
// Admin user: admin2@demo.com / admin123
// Scoped to Test ISP 2 tenant (for cross-tenant isolation tests)
// -----------------------------------------------------------------------
const admin2User = await prisma.user.upsert({
where: {
email_tenantId: {
email: "admin2@demo.com",
tenantId: demoTenant2.id,
},
},
update: {},
create: {
email: "admin2@demo.com",
passwordHash: adminPasswordHash,
firstName: "Demo",
lastName: "Admin2",
tenantId: demoTenant2.id,
roles: ["ADMIN"],
isActive: true,
isSuperAdmin: false,
},
});
console.log(`Admin2 user upserted: ${admin2User.email} (${admin2User.id})`);
console.log("\nSeed complete. Test credentials:"); console.log("\nSeed complete. Test credentials:");
console.log(" Admin: admin@demo.com / admin123"); console.log(" Admin: admin@demo.com / admin123");
console.log(" Admin2: admin2@demo.com / admin123");
console.log(" Super-admin: superadmin@netforge.com / super123"); console.log(" Super-admin: superadmin@netforge.com / super123");
} }

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { withSuperAdmin } from "@/lib/middleware/super-admin";
import { prisma } from "@/lib/prisma";
/**
* GET /api/admin/tenants/[id]
*
* Super-admin only: Returns single tenant details including user list.
* Returns 404 if tenant not found.
*
* Response shape:
* { id, name, slug, status, ownerEmail, createdAt, users: [...] }
*/
export const GET = withSuperAdmin<{ id: string }>(
async (_req: NextRequest, _ctx, params) => {
const tenantId = params?.id;
if (!tenantId) {
return NextResponse.json({ error: "Tenant ID required" }, { status: 400 });
}
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
include: {
users: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
roles: true,
isActive: true,
createdAt: true,
},
},
_count: {
select: { users: true },
},
},
});
if (!tenant) {
return NextResponse.json({ error: "Tenant not found" }, { status: 404 });
}
return NextResponse.json({
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
status: tenant.status,
ownerEmail: tenant.ownerEmail,
businessAddress: tenant.businessAddress,
contactPhone: tenant.contactPhone,
createdAt: tenant.createdAt,
updatedAt: tenant.updatedAt,
suspendedAt: tenant.suspendedAt,
gracePeriodEndsAt: tenant.gracePeriodEndsAt,
userCount: tenant._count.users,
subscriberCount: 0,
users: tenant.users,
});
}
);

View File

@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from "next/server";
import { withSuperAdmin } from "@/lib/middleware/super-admin";
import { prisma } from "@/lib/prisma";
import { TenantStatus } from "@prisma/client";
/**
* POST /api/admin/tenants/[id]/suspend
*
* Super-admin only: Suspend or activate a tenant.
*
* Request body: { action: "suspend" | "activate" }
*
* For "suspend":
* - Sets status to PENDING_SUSPENSION
* - Sets suspendedAt to now()
* - Sets gracePeriodEndsAt to 7 days from now
* - Returns message with grace period end date
*
* For "activate":
* - Sets status to ACTIVE
* - Clears suspendedAt and gracePeriodEndsAt
* - Returns confirmation message
*/
export const POST = withSuperAdmin<{ id: string }>(
async (req: NextRequest, _ctx, params) => {
const tenantId = params?.id;
if (!tenantId) {
return NextResponse.json({ error: "Tenant ID required" }, { status: 400 });
}
// Parse request body
let body: { action?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { action } = body;
if (!action || (action !== "suspend" && action !== "activate")) {
return NextResponse.json(
{ error: 'action must be "suspend" or "activate"' },
{ status: 400 }
);
}
// Check tenant exists
const existing = await prisma.tenant.findUnique({
where: { id: tenantId },
});
if (!existing) {
return NextResponse.json({ error: "Tenant not found" }, { status: 404 });
}
if (action === "suspend") {
const now = new Date();
const gracePeriodEndsAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const updated = await prisma.tenant.update({
where: { id: tenantId },
data: {
status: TenantStatus.PENDING_SUSPENSION,
suspendedAt: now,
gracePeriodEndsAt,
},
});
return NextResponse.json({
message: `Tenant suspension initiated. Grace period ends on ${updated.gracePeriodEndsAt?.toISOString()}.`,
tenant: {
id: updated.id,
status: updated.status,
suspendedAt: updated.suspendedAt,
gracePeriodEndsAt: updated.gracePeriodEndsAt,
},
});
} else {
// activate
const updated = await prisma.tenant.update({
where: { id: tenantId },
data: {
status: TenantStatus.ACTIVE,
suspendedAt: null,
gracePeriodEndsAt: null,
},
});
return NextResponse.json({
message: "Tenant activated.",
tenant: {
id: updated.id,
status: updated.status,
suspendedAt: updated.suspendedAt,
gracePeriodEndsAt: updated.gracePeriodEndsAt,
},
});
}
}
);

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { withSuperAdmin } from "@/lib/middleware/super-admin";
import { prisma } from "@/lib/prisma";
/**
* GET /api/admin/tenants
*
* Super-admin only: Lists ALL tenants on the platform (no tenant scoping).
* Returns tenant details including user count for monitoring purposes.
*
* Response shape:
* Array of { id, name, slug, status, ownerEmail, createdAt, userCount, subscriberCount }
* Sorted by createdAt descending (newest first).
*/
export const GET = withSuperAdmin(async (_req: NextRequest) => {
const tenants = await prisma.tenant.findMany({
orderBy: { createdAt: "desc" },
include: {
_count: {
select: { users: true },
},
},
});
const result = tenants.map((tenant) => ({
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
status: tenant.status,
ownerEmail: tenant.ownerEmail,
createdAt: tenant.createdAt,
updatedAt: tenant.updatedAt,
suspendedAt: tenant.suspendedAt,
gracePeriodEndsAt: tenant.gracePeriodEndsAt,
userCount: tenant._count.users,
// subscriberCount: 0 until Subscriber model is added in Phase 2
subscriberCount: 0,
}));
return NextResponse.json(result);
});

View File

@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
/**
* The context passed to super-admin route handlers.
* Handlers receive the session user confirmed to be a super-admin.
*/
export interface SuperAdminContext {
user: {
id: string;
email: string;
tenantId: string | null;
roles: string[];
isSuperAdmin: boolean;
firstName: string;
lastName: string;
};
}
/**
* Handler signature for super-admin route handlers.
* Receives the request and super-admin context with user info.
* Optionally receives route segment params (for dynamic routes).
*/
type SuperAdminHandler<P = Record<string, string>> = (
req: NextRequest,
ctx: SuperAdminContext,
params?: P
) => Promise<NextResponse> | NextResponse;
/**
* Higher-order function that wraps a Next.js route handler with super-admin enforcement.
*
* Flow:
* 1. Get current user session via getCurrentUser()
* 2. If no session → return 401 Unauthorized
* 3. If user.isSuperAdmin is not true → return 403 Forbidden
* 4. If authorized → call handler with super-admin context
*
* Usage (static route):
* ```typescript
* export const GET = withSuperAdmin(async (req, { user }) => {
* const tenants = await prisma.tenant.findMany();
* return NextResponse.json(tenants);
* });
* ```
*
* Usage (dynamic route with params):
* ```typescript
* export const GET = withSuperAdmin(async (req, { user }, params) => {
* const { id } = params;
* return NextResponse.json({ id });
* });
* ```
*
* @param handler - The route handler to wrap with super-admin protection
* @returns A Next.js route handler function
*/
export function withSuperAdmin<P = Record<string, string>>(
handler: SuperAdminHandler<P>
) {
return async function (
req: NextRequest,
routeContext?: { params: P | Promise<P> }
): Promise<NextResponse> {
// Step 1: Get session user
const user = await getCurrentUser();
// Step 2: No session → 401 Unauthorized
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Step 3: Not super-admin → 403 Forbidden
if (!user.isSuperAdmin) {
return NextResponse.json(
{ error: "Super-admin access required" },
{ status: 403 }
);
}
// Step 4: Resolve params (Next.js 15 may return Promise<params>)
let resolvedParams: P | undefined;
if (routeContext?.params) {
resolvedParams = routeContext.params instanceof Promise
? await routeContext.params
: routeContext.params;
}
// Step 5: Authorized → call handler with context and resolved params
return handler(req, { user }, resolvedParams);
};
}