feat(EPIC-11): admin layout + guard + banner [wave 1]

Foundation for the Super-Admin Platform sub-product:
- requirePlatformAdmin server helper: getUser → check platform_admins
  row + AAL2 (MFA recente). Redirects /admin/forbidden or /login/mfa.
- middleware.ts branches on host=admin.* OR path=/admin/* (early
  redirect to /login if no auth cookie). DB check stays server-side.
- AdminShell + PlatformModeBanner (sticky amber banner, role=region) +
  AdminSidebar (14 entries with distinct Phosphor icons).
- /admin redirects to /admin/dashboard (stub for S-11.02).
- /admin/forbidden 403 page outside the protected layout group.

In dev: works via http://localhost:3000/admin/* (no hosts file needed).
In prod: admin.deskcomm.com sub-domain via Vercel rewrites (deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rafael Melgaço
2026-04-29 13:09:49 -03:00
co-authored by Claude Opus 4.7
parent f45298e21e
commit 975b0242b5
13 changed files with 311 additions and 3 deletions
+12
View File
@@ -0,0 +1,12 @@
export const metadata = { title: "Dashboard — Admin Plataforma" };
export default function AdminDashboardPage() {
return (
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-sm text-muted-foreground">
Cross-tenant KPIs em breve (S-11.02).
</p>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
import { requirePlatformAdmin } from "@/lib/auth/requirePlatformAdmin";
import { AdminShell } from "@/components/admin/AdminShell";
export default async function ProtectedAdminLayout({ children }: { children: ReactNode }) {
const { user } = await requirePlatformAdmin();
return <AdminShell userEmail={user.email ?? ""}>{children}</AdminShell>;
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminIndexPage() {
redirect("/admin/dashboard");
}
+27
View File
@@ -0,0 +1,27 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
export const metadata = { title: "Acesso negado — Admin Plataforma" };
export default function AdminForbiddenPage() {
return (
<main className="flex min-h-screen items-center justify-center p-8">
<Card className="w-full max-w-lg p-8 text-center">
<h1 className="text-2xl font-semibold">Acesso negado</h1>
<p className="mt-3 text-sm text-muted-foreground">
Esta área é restrita a administradores da plataforma com MFA ativo.
Se você acredita que isso é um erro, contate o time de operações.
</p>
<div className="mt-6 flex justify-center gap-2">
<Button asChild variant="outline">
<Link href="/">Início</Link>
</Button>
<Button asChild>
<Link href="/app">Voltar para /app</Link>
</Button>
</div>
</Card>
</main>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { ReactNode } from "react";
/**
* Outermost /admin layout — intentionally minimal so that
* `app/admin/forbidden` (sibling of the `(protected)` group) can render
* without invoking `requirePlatformAdmin` and creating a redirect loop.
*
* The actual platform shell + guard lives in `(protected)/layout.tsx`.
*/
export default function AdminLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}
+24
View File
@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import { PlatformModeBanner } from "./PlatformModeBanner";
import { AdminSidebar } from "./AdminSidebar";
interface AdminShellProps {
userEmail: string;
children: ReactNode;
}
/**
* Server component shell for /admin/*. Renders the cross-tenant banner
* (sticky top), platform sidebar, and main content area.
*/
export function AdminShell({ userEmail, children }: AdminShellProps) {
return (
<div className="flex min-h-screen w-full flex-col bg-background">
<PlatformModeBanner />
<div className="flex flex-1">
<AdminSidebar userEmail={userEmail} />
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Gauge,
ChatsCircle,
Buildings,
ClipboardText,
Scales,
Warning,
ChartBar,
Users,
ShieldCheck,
ArrowRight,
} from "@/lib/ui/icons";
import type { Icon as PhosphorIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
interface NavItem {
href: string;
label: string;
icon: PhosphorIcon;
}
const NAV_ITEMS: NavItem[] = [
{ href: "/admin/dashboard", label: "Dashboard", icon: Gauge },
{ href: "/admin/inbox", label: "Inbox", icon: ChatsCircle },
{ href: "/admin/tenants", label: "Tenants", icon: Buildings },
{ href: "/admin/audit", label: "Audit", icon: ClipboardText },
{ href: "/admin/lgpd", label: "LGPD", icon: Scales },
{ href: "/admin/incidents", label: "Incidents", icon: Warning },
{ href: "/admin/usage", label: "Usage", icon: ChartBar },
{ href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/platform-admins", label: "Platform Admins", icon: ShieldCheck },
];
interface AdminSidebarProps {
userEmail: string;
}
export function AdminSidebar({ userEmail }: AdminSidebarProps) {
const pathname = usePathname();
return (
<aside className="flex w-60 shrink-0 flex-col border-r bg-card">
<div className="flex h-14 items-center border-b px-4">
<div className="flex flex-col">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
DeskcommCRM
</span>
<span className="text-sm font-semibold tracking-tight">Admin Plataforma</span>
</div>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto p-2" aria-label="Navegação plataforma">
{NAV_ITEMS.map((item) => {
const isActive =
pathname === item.href || pathname.startsWith(item.href + "/");
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<Icon size={18} weight={isActive ? "fill" : "regular"} aria-hidden />
<span className="truncate">{item.label}</span>
</Link>
);
})}
</nav>
<div className="space-y-2 border-t p-3">
<Link
href="/app"
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent/50 hover:text-foreground"
>
<ArrowRight size={14} aria-hidden />
<span>Voltar pra app</span>
</Link>
<p className="truncate px-2 text-xs text-muted-foreground" title={userEmail}>
{userEmail}
</p>
</div>
</aside>
);
}
+29
View File
@@ -0,0 +1,29 @@
import Link from "next/link";
import { Buildings } from "@/lib/ui/icons";
/**
* Sticky top banner that signals the user is operating in cross-tenant
* Platform mode. Persistent visual cue to prevent accidental destructive
* actions when the operator forgets which surface they're in.
*/
export function PlatformModeBanner() {
return (
<div
role="region"
aria-label="Modo Plataforma"
className="sticky top-0 z-40 flex h-10 w-full items-center justify-between border-b border-amber-300 bg-amber-100 px-4 text-amber-900"
>
<div className="flex items-center gap-2 text-sm">
<Buildings size={18} weight="fill" aria-hidden />
<span className="font-semibold tracking-tight">MODO PLATAFORMA</span>
<span className="hidden text-amber-800/80 sm:inline">— operação cross-tenant</span>
</div>
<Link
href="/app"
className="rounded-md px-2 py-1 text-xs font-medium underline-offset-2 hover:underline"
>
Sair pra app pessoal
</Link>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Client-side placeholder hook for /admin pages.
*
* The authoritative guard is server-side (`requirePlatformAdmin` in
* `app/admin/(protected)/layout.tsx`). This hook exists so client components
* within the admin shell have a hook signature to opt into in the future
* (e.g. realtime checks for revoked_at). For now it's a no-op.
*/
"use client";
export function useAdminGuard(): void {
// Intentionally empty. Server guard is the source of truth.
}
+1
View File
@@ -6,6 +6,7 @@ export const PUBLIC_PATHS: RegExp[] = [
/^\/$/,
/^\/login(\/.*)?$/,
/^\/403$/,
/^\/admin\/forbidden$/,
/^\/404$/,
/^\/500$/,
/^\/503$/,
+71
View File
@@ -0,0 +1,71 @@
/**
* Server guard for /admin/* (Super-Admin Platform sub-product).
*
* Flow:
* 1. Validate JWT via getUser() (NEVER getSession on backend per CLAUDE.md).
* 2. Confirm row in platform_admins (active = no revoked_at).
* 3. Enforce MFA AAL2 if `mfa_required` (default true for platform admins).
*
* Redirects:
* - no user → /login?next=/admin
* - no row → /admin/forbidden
* - aal1 + required → /login/mfa?next=/admin
*
* The middleware already does an early `fn_is_platform_admin` RPC check;
* this helper performs the authoritative server-side validation inside the
* /admin layout (where redirects are cheap, DB calls are allowed in Node
* runtime, and we have access to AAL state).
*/
import { redirect } from "next/navigation";
import type { User } from "@supabase/supabase-js";
import { createClient } from "@/lib/supabase/server";
export interface PlatformAdminInfo {
user_id: string;
scope: string;
mfa_required: boolean;
}
export interface PlatformAdminContext {
user: User;
platformAdmin: PlatformAdminInfo;
}
export async function requirePlatformAdmin(): Promise<PlatformAdminContext> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login?next=/admin");
}
// platform_admins RLS: only platform admins read; non-admins get null → forbid.
const { data: paRow } = await supabase
.from("platform_admins")
.select("user_id, scope, mfa_required, revoked_at")
.eq("user_id", user.id)
.is("revoked_at", null)
.maybeSingle();
if (!paRow) {
redirect("/admin/forbidden");
}
if (paRow.mfa_required) {
const { data: aalData } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
if (aalData?.currentLevel !== "aal2") {
redirect("/login/mfa?next=/admin");
}
}
return {
user,
platformAdmin: {
user_id: paRow.user_id,
scope: paRow.scope,
mfa_required: paRow.mfa_required,
},
};
}
+6
View File
@@ -18,6 +18,12 @@ export {
ShieldCheck,
Gear,
House,
// admin platform
Buildings,
ChatsCircle,
ClipboardText,
Scales,
Gauge,
// actions
PaperPlaneTilt,
Check,
+12 -3
View File
@@ -17,6 +17,13 @@ export async function middleware(request: NextRequest) {
response.headers.set("x-pathname", pathname);
request.headers.set("x-pathname", pathname);
// EPIC-11: in dev we route by path (`/admin/*`); in prod the
// `admin.deskcomm.com` sub-domain is mapped via Vercel rewrites to the same
// `/admin/*` paths. The host-based branch below stays a NOOP today and only
// exists as documentation of the intended deploy topology.
const host = request.headers.get("host") ?? "";
const isAdminSurface = host.startsWith("admin.") || pathname.startsWith("/admin");
if (isPublicPath(pathname)) {
return response;
}
@@ -57,11 +64,13 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(loginUrl);
}
// /admin/* additionally requires platform_admin
if (pathname.startsWith("/admin")) {
// /admin/* additionally requires platform_admin (early gate — authoritative
// check is server-side in `requirePlatformAdmin`). Skip the RPC for
// `/admin/forbidden` (rendered to non-admins, would otherwise loop).
if (isAdminSurface && pathname.startsWith("/admin") && pathname !== "/admin/forbidden") {
const { data: isAdmin, error } = await supabase.rpc("fn_is_platform_admin");
if (error || !isAdmin) {
return NextResponse.redirect(new URL("/403", request.url));
return NextResponse.redirect(new URL("/admin/forbidden", request.url));
}
}