feat(EPIC-11): platform-admins read-only [wave 14]

GET /api/v1/admin/platform-admins: requirePlatformAdmin, service-role
list of platform_admins with auth.users emails resolved (granted_by,
revoked_by, target user). POST/PATCH/DELETE return 405 explicitly with
message pointing to Spec 01 §3.4 T-04 (DBA-only mutation).

UI:
- /admin/platform-admins: prominent DBAOnlyNotice (blue alert with
  T-04 reference + runbook link) + PlatformAdminsTable (user, granted_at
  relative, granted_by, scope, mfa_required, status active/revoked,
  reason tooltip)
- ZERO mutation buttons by design

Closes EPIC-11 — Super-Admin Platform fully wired (14/14 waves).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rafael Melgaço
2026-04-29 15:45:12 -03:00
co-authored by Claude Opus 4.7
parent 04931eac69
commit 09379256a4
7 changed files with 507 additions and 1 deletions
@@ -0,0 +1,37 @@
"use client";
import { DBAOnlyNotice } from "@/components/admin/platform-admins/DBAOnlyNotice";
import {
PlatformAdminsTable,
PlatformAdminsTableSkeleton,
} from "@/components/admin/platform-admins/PlatformAdminsTable";
import { useAdminPlatformAdmins } from "@/hooks/useAdminPlatformAdmins";
export function PlatformAdminsClient() {
const { data, isLoading, isError } = useAdminPlatformAdmins();
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Platform Admins</h1>
<p className="mt-1 text-sm text-muted-foreground">
Administradores com acesso privilegiado à plataforma
</p>
</div>
{/* T-04 Notice — proeminente, antes da tabela */}
<DBAOnlyNotice />
{/* Table */}
{isLoading ? (
<PlatformAdminsTableSkeleton />
) : isError ? (
<div className="flex items-center justify-center rounded-lg border py-12 text-sm text-muted-foreground">
Erro ao carregar platform admins. Tente recarregar.
</div>
) : (
<PlatformAdminsTable data={data ?? []} />
)}
</div>
);
}
@@ -0,0 +1,7 @@
import { PlatformAdminsClient } from "./_client";
export const metadata = { title: "Platform Admins — Admin Plataforma" };
export default function AdminPlatformAdminsPage() {
return <PlatformAdminsClient />;
}
+168
View File
@@ -0,0 +1,168 @@
import { type NextRequest } from "next/server";
import { requirePlatformAdmin } from "@/lib/auth/requirePlatformAdmin";
import { createAdminClient } from "@/lib/supabase/admin";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { randomUUID } from "node:crypto";
// ---------------------------------------------------------------------------
// T-04 (Spec 01 §3.4): platform_admins is managed exclusively by DBA via SQL.
// This route is strictly READ-ONLY. POST/PATCH/DELETE return 405 explicitly.
// ---------------------------------------------------------------------------
const T04_MESSAGE =
"platform_admins é gerenciado exclusivamente via DBA (Spec 01 §3.4 T-04)";
// ---------------------------------------------------------------------------
// GET /api/v1/admin/platform-admins
// ---------------------------------------------------------------------------
export async function GET(_req: NextRequest) {
const requestId = randomUUID();
let adminCtx: Awaited<ReturnType<typeof requirePlatformAdmin>>;
try {
adminCtx = await requirePlatformAdmin();
} catch {
return fail("forbidden", "Platform admin required", 403, { requestId });
}
const admin = createAdminClient();
// Step 1: fetch all platform_admins rows
const { data: paRows, error: paError } = await admin
.from("platform_admins")
.select(
"id, user_id, granted_by, granted_at, scope, mfa_required, reason, revoked_at, revoked_by, revoke_reason",
)
.order("granted_at", { ascending: false });
if (paError) {
return fail("internal_error", "Query failed", 500, {
requestId,
details: paError.message,
});
}
if (!paRows || paRows.length === 0) {
void audit({
action: "platform_admin.platform_admins_listed",
actorUserId: adminCtx.user.id,
actingAsPlatformAdmin: true,
bypassedRls: true,
requestId,
metadata: { result_count: 0 },
});
return ok([], { requestId });
}
// Step 2: collect all user IDs that need resolution (user, granted_by, revoked_by)
const userIdSet = new Set<string>();
for (const row of paRows) {
userIdSet.add(row.user_id);
if (row.granted_by) userIdSet.add(row.granted_by);
if (row.revoked_by) userIdSet.add(row.revoked_by);
}
const allUserIds = Array.from(userIdSet);
// Step 3: resolve auth.users emails via service-role (cross-schema join)
const { data: authUsersData, error: authError } = await admin
.schema("auth")
.from("users")
.select("id, email, raw_user_meta_data")
.in("id", allUserIds);
if (authError) {
return fail("internal_error", "Auth user query failed", 500, {
requestId,
details: authError.message,
});
}
type AuthUser = {
id: string;
email: string | null;
raw_user_meta_data: Record<string, unknown> | null;
};
const authMap = new Map<string, AuthUser>(
((authUsersData as AuthUser[]) ?? []).map((u) => [u.id, u]),
);
// Step 4: build enriched rows
const data = paRows.map((pa) => {
const targetUser = authMap.get(pa.user_id);
const grantedByUser = pa.granted_by ? authMap.get(pa.granted_by) : null;
const revokedByUser = pa.revoked_by ? authMap.get(pa.revoked_by) : null;
return {
id: pa.id,
user_id: pa.user_id,
user_email: targetUser?.email ?? null,
user_name:
(targetUser?.raw_user_meta_data?.full_name as string | undefined) ??
null,
granted_by: pa.granted_by,
granted_by_email: grantedByUser?.email ?? null,
granted_at: pa.granted_at,
scope: pa.scope,
mfa_required: pa.mfa_required,
reason: pa.reason,
revoked_at: pa.revoked_at,
revoked_by: pa.revoked_by,
revoked_by_email: revokedByUser?.email ?? null,
revoke_reason: pa.revoke_reason,
};
});
void audit({
action: "platform_admin.platform_admins_listed",
actorUserId: adminCtx.user.id,
actingAsPlatformAdmin: true,
bypassedRls: true,
requestId,
metadata: { result_count: data.length },
});
return ok(data, { requestId });
}
// ---------------------------------------------------------------------------
// T-04 enforcement: POST / PATCH / DELETE return 405 explicitly
// ---------------------------------------------------------------------------
function methodNotAllowed() {
return new Response(
JSON.stringify({
error: {
code: "method_not_allowed",
message: T04_MESSAGE,
},
}),
{
status: 405,
headers: {
"Content-Type": "application/json",
Allow: "GET",
},
},
);
}
export function POST() {
return methodNotAllowed();
}
export function PATCH() {
return methodNotAllowed();
}
export function DELETE() {
return methodNotAllowed();
}
export type PlatformAdminRow = Awaited<
ReturnType<typeof GET>
> extends Response
? never
: never;
@@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
import { Info } from "@/lib/ui/icons";
export function DBAOnlyNotice() {
return (
<div
role="note"
className="flex gap-3 rounded-lg border border-blue-200 bg-blue-50 p-4 text-blue-900"
>
<Info size={20} className="mt-0.5 shrink-0 text-blue-600" aria-hidden />
<div className="space-y-1">
<p className="text-sm font-semibold">
Gerenciamento de Platform Admins é restrito ao DBA
</p>
<p className="text-sm leading-relaxed text-blue-800">
Conforme Spec 01 §3.4 T-04: adição, remoção ou alteração de{" "}
<code className="rounded bg-blue-100 px-1 font-mono text-xs">
platform_admins
</code>{" "}
é feita exclusivamente via SQL pelo DBA, com nota explicativa em{" "}
<code className="rounded bg-blue-100 px-1 font-mono text-xs">
api_audit_log
</code>
. Esta página é informativa e read-only — nenhum botão de modificação
está disponível por design.
</p>
<p className="pt-1">
<Link
href="/runbook/platform-admin-management.md"
className="text-xs font-medium text-blue-700 underline underline-offset-2 hover:text-blue-900"
>
Ver runbook →
</Link>
</p>
</div>
</div>
);
}
@@ -0,0 +1,219 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { PlatformAdminEntry } from "@/hooks/useAdminPlatformAdmins";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function relativeDate(iso: string): string {
try {
return formatDistanceToNow(new Date(iso), { addSuffix: true, locale: ptBR });
} catch {
return iso;
}
}
function shortEmail(email: string | null): string {
if (!email) return "—";
const [local, domain] = email.split("@");
if (!domain) return email;
const shortDomain = domain.split(".")[0];
return `${local}@${shortDomain}`;
}
// ---------------------------------------------------------------------------
// Skeleton
// ---------------------------------------------------------------------------
export function PlatformAdminsTableSkeleton() {
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{["Usuário", "Concedido em", "Concedido por", "Scope", "MFA", "Status", "Motivo"].map(
(h) => (
<TableHead key={h}>{h}</TableHead>
),
)}
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 4 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: 7 }).map((__, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-16 text-center">
<p className="text-sm font-medium text-muted-foreground">
Nenhum platform admin encontrado
</p>
<p className="mt-1 text-xs text-muted-foreground">
Platform admins são configurados exclusivamente via DBA.
</p>
</div>
);
}
// ---------------------------------------------------------------------------
// Reason cell with tooltip for long text
// ---------------------------------------------------------------------------
function ReasonCell({ reason }: { reason: string | null }) {
if (!reason) return <span className="text-muted-foreground">—</span>;
const MAX = 40;
if (reason.length <= MAX) {
return <span className="text-xs">{reason}</span>;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help truncate text-xs underline decoration-dotted">
{reason.slice(0, MAX)}…
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs break-words">{reason}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// ---------------------------------------------------------------------------
// Table
// ---------------------------------------------------------------------------
interface PlatformAdminsTableProps {
data: PlatformAdminEntry[];
}
export function PlatformAdminsTable({ data }: PlatformAdminsTableProps) {
if (data.length === 0) return <EmptyState />;
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[200px]">Usuário</TableHead>
<TableHead className="w-[140px]">Concedido em</TableHead>
<TableHead className="w-[160px]">Concedido por</TableHead>
<TableHead className="w-[120px]">Scope</TableHead>
<TableHead className="w-[60px]">MFA</TableHead>
<TableHead className="w-[90px]">Status</TableHead>
<TableHead>Motivo</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((row) => {
const isRevoked = !!row.revoked_at;
return (
<TableRow key={row.id} className={isRevoked ? "opacity-60" : undefined}>
{/* User */}
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{row.user_email ?? (
<span className="font-mono text-xs text-muted-foreground">
{row.user_id.slice(0, 8)}
</span>
)}
</span>
{row.user_name && (
<span className="text-xs text-muted-foreground">{row.user_name}</span>
)}
</div>
</TableCell>
{/* Granted At */}
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{relativeDate(row.granted_at)}
</TableCell>
{/* Granted By */}
<TableCell className="text-xs text-muted-foreground">
{shortEmail(row.granted_by_email)}
</TableCell>
{/* Scope */}
<TableCell>
<Badge variant="outline" className="text-[10px] font-mono">
{row.scope ?? "platform"}
</Badge>
</TableCell>
{/* MFA Required */}
<TableCell>
{row.mfa_required ? (
<Badge variant="default" className="text-[10px]">
Sim
</Badge>
) : (
<Badge variant="secondary" className="text-[10px]">
Não
</Badge>
)}
</TableCell>
{/* Status */}
<TableCell>
{isRevoked ? (
<Badge variant="destructive" className="text-[10px]">
Revogado
</Badge>
) : (
<Badge
variant="outline"
className="border-green-500 text-[10px] text-green-700"
>
Ativo
</Badge>
)}
</TableCell>
{/* Reason */}
<TableCell className="max-w-[200px]">
<ReasonCell reason={row.reason} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
export interface PlatformAdminEntry {
id: string;
user_id: string;
user_email: string | null;
user_name: string | null;
granted_by: string | null;
granted_by_email: string | null;
granted_at: string;
scope: string | null;
mfa_required: boolean;
reason: string | null;
revoked_at: string | null;
revoked_by: string | null;
revoked_by_email: string | null;
revoke_reason: string | null;
}
interface PlatformAdminsResponse {
data: PlatformAdminEntry[];
}
export function useAdminPlatformAdmins() {
return useQuery({
queryKey: ["admin", "platform-admins"],
queryFn: () =>
apiClient
.get<PlatformAdminsResponse>("/api/v1/admin/platform-admins")
.then((r) => r.data),
staleTime: 5 * 60 * 1000, // 5 min — changes are rare (DBA-only mutations)
});
}
+2 -1
View File
@@ -95,4 +95,5 @@ export type AuditAction =
| "incident.resolved"
| "platform_admin.usage_viewed"
| "platform_admin.users_listed"
| "platform_admin.user_viewed";
| "platform_admin.user_viewed"
| "platform_admin.platform_admins_listed";