feat(01-05): super-admin UI panel and comprehensive test harness

- (super-admin)/layout.tsx: server guard (isSuperAdmin check), sidebar nav
- (super-admin)/admin/page.tsx: dashboard with tenant stats (total/active/suspended)
- (super-admin)/admin/tenants/page.tsx: tenant table with status badges, suspend/activate
- src/middleware.ts: /admin/* routes require isSuperAdmin in JWT token
- src/lib/__tests__/super-admin.test.ts: 11 tests covering middleware guard + suspension logic
- All 93 tests pass (auth 8, RBAC 66, isolation 6, super-admin 11, setup 2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-04 19:06:53 +08:00
parent df40eae328
commit 25a12effb0
5 changed files with 843 additions and 1 deletions

View File

@@ -0,0 +1,123 @@
import { headers } from "next/headers";
/**
* Admin Dashboard page — /admin
*
* Shows platform-level summary statistics:
* - Total tenants
* - Active tenants
* - Pending suspension / suspended tenants
*
* Fetches data from /api/admin/tenants (super-admin only endpoint).
* This is a server component, so it fetches during SSR.
*/
interface TenantSummary {
id: string;
name: string;
status: "ACTIVE" | "PENDING_SUSPENSION" | "SUSPENDED";
}
async function getTenantStats(): Promise<{
total: number;
active: number;
pendingSuspension: number;
suspended: number;
} | null> {
try {
const headersList = await headers();
const host = headersList.get("host") ?? "localhost:3000";
const protocol = process.env.NODE_ENV === "production" ? "https" : "http";
const cookie = headersList.get("cookie") ?? "";
const res = await fetch(`${protocol}://${host}/api/admin/tenants`, {
headers: { cookie },
cache: "no-store",
});
if (!res.ok) return null;
const tenants: TenantSummary[] = await res.json();
return {
total: tenants.length,
active: tenants.filter((t) => t.status === "ACTIVE").length,
pendingSuspension: tenants.filter(
(t) => t.status === "PENDING_SUSPENSION"
).length,
suspended: tenants.filter((t) => t.status === "SUSPENDED").length,
};
} catch {
return null;
}
}
export default async function AdminDashboardPage() {
const stats = await getTenantStats();
return (
<div>
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900">Dashboard</h2>
<p className="text-gray-600 mt-1">Platform overview</p>
</div>
{stats ? (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{/* Total Tenants */}
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wide">
Total Tenants
</div>
<div className="mt-2 text-3xl font-bold text-gray-900">
{stats.total}
</div>
</div>
{/* Active Tenants */}
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wide">
Active
</div>
<div className="mt-2 text-3xl font-bold text-green-600">
{stats.active}
</div>
</div>
{/* Pending Suspension */}
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wide">
Pending Suspension
</div>
<div className="mt-2 text-3xl font-bold text-yellow-600">
{stats.pendingSuspension}
</div>
</div>
{/* Suspended */}
<div className="bg-white rounded-lg shadow p-6">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wide">
Suspended
</div>
<div className="mt-2 text-3xl font-bold text-red-600">
{stats.suspended}
</div>
</div>
</div>
) : (
<div className="bg-white rounded-lg shadow p-6 text-gray-500">
Could not load tenant statistics.
</div>
)}
<div className="mt-8">
<a
href="/admin/tenants"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700"
>
Manage Tenants
</a>
</div>
</div>
);
}

View File

@@ -0,0 +1,292 @@
"use client";
import { useEffect, useState, useCallback } from "react";
/**
* Tenant management page — /admin/tenants
*
* Client component that:
* - Fetches all tenants from /api/admin/tenants
* - Displays a table with: Name, Status (color badge), Owner Email, Users, Created Date
* - Each row has Suspend/Activate toggle action button
* - Suspend shows a confirmation dialog before proceeding
* - Status updates after action without page reload
*/
type TenantStatus = "ACTIVE" | "PENDING_SUSPENSION" | "SUSPENDED";
interface Tenant {
id: string;
name: string;
slug: string;
status: TenantStatus;
ownerEmail: string;
userCount: number;
subscriberCount: number;
createdAt: string;
suspendedAt: string | null;
gracePeriodEndsAt: string | null;
}
function StatusBadge({ status }: { status: TenantStatus }) {
const styles: Record<TenantStatus, string> = {
ACTIVE: "bg-green-100 text-green-800",
PENDING_SUSPENSION: "bg-yellow-100 text-yellow-800",
SUSPENDED: "bg-red-100 text-red-800",
};
const labels: Record<TenantStatus, string> = {
ACTIVE: "Active",
PENDING_SUSPENSION: "Pending Suspension",
SUSPENDED: "Suspended",
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status]}`}
>
{labels[status]}
</span>
);
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export default function TenantsPage() {
const [tenants, setTenants] = useState<Tenant[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const fetchTenants = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch("/api/admin/tenants");
if (!res.ok) {
setError("Failed to load tenants");
return;
}
const data: Tenant[] = await res.json();
setTenants(data);
} catch {
setError("Failed to connect to server");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTenants();
}, [fetchTenants]);
const handleSuspend = async (tenant: Tenant) => {
const confirmed = window.confirm(
`Are you sure you want to suspend "${tenant.name}"?\n\n` +
`The tenant will have a 7-day grace period before service is interrupted.`
);
if (!confirmed) return;
setActionLoading(tenant.id);
try {
const res = await fetch(`/api/admin/tenants/${tenant.id}/suspend`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "suspend" }),
});
if (!res.ok) {
const data = await res.json();
alert(`Failed to suspend tenant: ${data.error ?? "Unknown error"}`);
return;
}
// Refresh tenant list
await fetchTenants();
} catch {
alert("Failed to suspend tenant. Please try again.");
} finally {
setActionLoading(null);
}
};
const handleActivate = async (tenant: Tenant) => {
setActionLoading(tenant.id);
try {
const res = await fetch(`/api/admin/tenants/${tenant.id}/suspend`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "activate" }),
});
if (!res.ok) {
const data = await res.json();
alert(`Failed to activate tenant: ${data.error ?? "Unknown error"}`);
return;
}
// Refresh tenant list
await fetchTenants();
} catch {
alert("Failed to activate tenant. Please try again.");
} finally {
setActionLoading(null);
}
};
if (loading) {
return (
<div>
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900">Tenants</h2>
<p className="text-gray-600 mt-1">Manage all ISP tenants on the platform</p>
</div>
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-500">
Loading tenants...
</div>
</div>
);
}
if (error) {
return (
<div>
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900">Tenants</h2>
</div>
<div className="bg-white rounded-lg shadow p-8 text-center">
<p className="text-red-600 mb-4">{error}</p>
<button
onClick={fetchTenants}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Retry
</button>
</div>
</div>
);
}
return (
<div>
<div className="mb-8 flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">Tenants</h2>
<p className="text-gray-600 mt-1">
{tenants.length} tenant{tenants.length !== 1 ? "s" : ""} on the platform
</p>
</div>
<button
onClick={fetchTenants}
className="px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Refresh
</button>
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Owner Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Users
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{tenants.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-6 py-8 text-center text-sm text-gray-500"
>
No tenants found.
</td>
</tr>
) : (
tenants.map((tenant) => (
<tr key={tenant.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{tenant.name}
</div>
<div className="text-xs text-gray-500">{tenant.slug}</div>
{tenant.gracePeriodEndsAt && (
<div className="text-xs text-yellow-600 mt-0.5">
Grace period ends: {formatDate(tenant.gracePeriodEndsAt)}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={tenant.status} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{tenant.ownerEmail}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{tenant.userCount}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(tenant.createdAt)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm">
<div className="flex items-center justify-end gap-2">
<a
href={`/admin/tenants/${tenant.id}`}
className="text-blue-600 hover:text-blue-800 font-medium"
>
View
</a>
{tenant.status === "ACTIVE" ? (
<button
onClick={() => handleSuspend(tenant)}
disabled={actionLoading === tenant.id}
className="text-red-600 hover:text-red-800 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{actionLoading === tenant.id
? "Suspending..."
: "Suspend"}
</button>
) : (
<button
onClick={() => handleActivate(tenant)}
disabled={actionLoading === tenant.id}
className="text-green-600 hover:text-green-800 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{actionLoading === tenant.id
? "Activating..."
: "Activate"}
</button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
/**
* Super-admin layout — server component.
*
* Guards all /admin/* routes. If the current user is not a super-admin,
* redirects to /login. This provides a second layer of protection on top
* of the API-level withSuperAdmin() middleware.
*
* Layout structure:
* - Sidebar with Dashboard and Tenants navigation
* - Header with "NetForge Admin" branding and user name
* - Main content area
*/
export default async function SuperAdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getCurrentUser();
// Guard: must be authenticated and be a super-admin
if (!user) {
redirect("/login");
}
if (!user.isSuperAdmin) {
// Non-super-admin gets a forbidden page, not a redirect loop
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Access Forbidden</h1>
<p className="text-gray-600 mb-4">
You do not have permission to access the admin panel.
</p>
<a
href="/login"
className="text-blue-600 hover:text-blue-800 underline"
>
Return to login
</a>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-100 flex">
{/* Sidebar */}
<aside className="w-64 bg-gray-900 text-white flex flex-col">
{/* Brand */}
<div className="px-6 py-4 border-b border-gray-700">
<h1 className="text-lg font-bold text-white">NetForge Admin</h1>
<p className="text-xs text-gray-400 mt-1">Platform Management</p>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-4 space-y-1">
<a
href="/admin"
className="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
>
<svg
className="mr-3 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
Dashboard
</a>
<a
href="/admin/tenants"
className="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
>
<svg
className="mr-3 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
Tenants
</a>
</nav>
{/* User info + Sign out */}
<div className="px-4 py-4 border-t border-gray-700">
<div className="flex items-center mb-3">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-sm font-medium text-white">
{user.firstName?.[0] ?? "S"}
</div>
<div className="ml-3">
<p className="text-sm font-medium text-white">
{user.firstName} {user.lastName}
</p>
<p className="text-xs text-gray-400">{user.email}</p>
</div>
</div>
<a
href="/api/auth/signout"
className="block w-full text-center px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
>
Sign out
</a>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<div className="px-8 py-8">{children}</div>
</main>
</div>
);
}