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:
123
src/app/(super-admin)/admin/page.tsx
Normal file
123
src/app/(super-admin)/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
292
src/app/(super-admin)/admin/tenants/page.tsx
Normal file
292
src/app/(super-admin)/admin/tenants/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
src/app/(super-admin)/layout.tsx
Normal file
128
src/app/(super-admin)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user