mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Opus 4.7
parent
f45298e21e
commit
975b0242b5
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminIndexPage() {
|
||||
redirect("/admin/dashboard");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export const PUBLIC_PATHS: RegExp[] = [
|
||||
/^\/$/,
|
||||
/^\/login(\/.*)?$/,
|
||||
/^\/403$/,
|
||||
/^\/admin\/forbidden$/,
|
||||
/^\/404$/,
|
||||
/^\/500$/,
|
||||
/^\/503$/,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,12 @@ export {
|
||||
ShieldCheck,
|
||||
Gear,
|
||||
House,
|
||||
// admin platform
|
||||
Buildings,
|
||||
ChatsCircle,
|
||||
ClipboardText,
|
||||
Scales,
|
||||
Gauge,
|
||||
// actions
|
||||
PaperPlaneTilt,
|
||||
Check,
|
||||
|
||||
+12
-3
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user