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:
@@ -82,8 +82,51 @@ async function main() {
|
||||
|
||||
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(" Admin: admin@demo.com / admin123");
|
||||
console.log(" Admin2: admin2@demo.com / admin123");
|
||||
console.log(" Super-admin: superadmin@netforge.com / super123");
|
||||
}
|
||||
|
||||
|
||||
63
src/app/api/admin/tenants/[id]/route.ts
Normal file
63
src/app/api/admin/tenants/[id]/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
);
|
||||
102
src/app/api/admin/tenants/[id]/suspend/route.ts
Normal file
102
src/app/api/admin/tenants/[id]/suspend/route.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
41
src/app/api/admin/tenants/route.ts
Normal file
41
src/app/api/admin/tenants/route.ts
Normal 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);
|
||||
});
|
||||
93
src/lib/middleware/super-admin.ts
Normal file
93
src/lib/middleware/super-admin.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user