mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Opus 4.7
parent
04931eac69
commit
09379256a4
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user