From 18dc9e726c7463656c3bf525a025cc93ceae55ef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 02:15:23 +0000 Subject: [PATCH 1/6] fix(supabase): realign frontend with live DB schema Regenerate src/integrations/supabase/types.ts from the live database (141->296 tables, 1->115 views, 65->385 functions) so the typed client matches reality, then fix every drift the accurate types surfaced. - Decode src/lib/supabase-untyped.ts and scripts/lint-untyped-from.sh, which were committed base64-encoded since #319 and broke compilation. - Fix nullability drift across admin/security, dashboard, quotes and intelligence hooks (DB columns are nullable; code assumed non-null). - Fix real runtime bugs hidden by the stale types: - quotes.client_name is NOT NULL -> stop inserting null. - quote_items.subtotal is required -> compute it on insert. - search_analytics uses user_id (not seller_id) and has no filters_used column -> correct the insert. - Restore the set_quote_number BEFORE INSERT trigger (migration) lost in the migration-replay drift; without it quote creation fails the quote_number NOT NULL constraint. Inserts now pass an empty quote_number for the trigger to populate. - Migrate intelligence untypedFrom() calls to typed supabase.from(); drop now-unnecessary `as any` casts in the kill-switch client/telemetry. tsc baseline: 484 errors (-24 vs 508, no regressions). eslint baseline clean. Build and affected unit tests pass. --- .eslint-baseline.json | 17 +- .../admin/SellerDiscountLimitsPanel.tsx | 75 +- .../admin/security/ActiveIpsList.tsx | 2 +- .../admin/security/RecentAuditTable.tsx | 225 +- .../security/keys/audit/useMcpAuditFeed.ts | 9 +- .../security/keys/audit/useStepUpAttempts.ts | 97 +- .../admin/security/keys/useMcpKeys.ts | 126 +- .../role-migration/RoleMigrationPanel.tsx | 351 +- .../admin/users/RoleAuditLogPanel.tsx | 162 +- .../admin/users/useUserManagement.ts | 195 +- src/components/dashboard/MyClientsWidget.tsx | 10 +- .../dashboard/MyRecentQuotesWidget.tsx | 4 +- src/components/dashboard/RecentKitsWidget.tsx | 73 +- src/components/expert/chat/useExpertChat.ts | 577 +- .../favorites/FavoritesTrashView.tsx | 83 +- src/components/filters/FilterPresets.ts | 26 +- .../intelligence/TopCategoriesCard.tsx | 75 +- src/components/intelligence/TrendsHeatmap.tsx | 44 +- src/components/search/useGlobalSearch.ts | 2 +- src/components/security/SecurityDashboard.tsx | 3 +- src/hooks/intelligence/useSalesGoals.ts | 19 +- src/hooks/intelligence/useSalesHistory.ts | 60 +- .../intelligence/useSalesHistoryMacro.ts | 127 +- src/hooks/kit-builder/useKitCollaboration.ts | 2 +- src/hooks/products/useProductAnalytics.ts | 11 +- src/hooks/products/useProductInsights.ts | 50 +- src/hooks/quotes/quoteHelpers.ts | 89 +- src/hooks/quotes/useDiscountApproval.ts | 450 +- src/hooks/quotes/useQuoteTemplates.ts | 14 +- src/hooks/quotes/useQuoteVersions.ts | 368 +- src/hooks/simulation/useSimulation.ts | 2 +- src/hooks/useKillSwitchBanner.ts | 5 +- src/integrations/supabase/types.ts | 36569 ++++++++++++++-- .../__tests__/kill-switch-client.test.ts | 2 +- src/lib/external-db/bridge-status-events.ts | 20 +- src/lib/external-db/kill-switch-client.ts | 14 +- src/lib/external-db/kill-switch-telemetry.ts | 11 +- .../admin/SellerDiscountLimitsAdminPage.tsx | 27 +- src/pages/kit-builder/useKitBuilderQuote.ts | 3 + ...20848_restore_set_quote_number_trigger.sql | 19 + 40 files changed, 33927 insertions(+), 6091 deletions(-) create mode 100644 supabase/migrations/20260525020848_restore_set_quote_number_trigger.sql diff --git a/.eslint-baseline.json b/.eslint-baseline.json index a3f284bf0..1adf50669 100644 --- a/.eslint-baseline.json +++ b/.eslint-baseline.json @@ -1,6 +1,6 @@ { - "generatedAt": "2026-05-24T19:38:14.033Z", - "totalErrors": 128, + "generatedAt": "2026-05-25T02:40:18.480Z", + "totalErrors": 133, "counts": { "src/components/access/DevAccessDeniedPage.tsx": { "react-hooks/exhaustive-deps": 1 @@ -123,7 +123,6 @@ "@typescript-eslint/naming-convention": 2 }, "src/components/expert/chat/useExpertChat.ts": { - "@typescript-eslint/no-unused-expressions": 2, "react-hooks/exhaustive-deps": 3 }, "src/components/filters/CommemorativeDateFilter.tsx": { @@ -424,6 +423,12 @@ "src/hooks/admin/useDevGate.ts": { "react-hooks/exhaustive-deps": 1 }, + "src/hooks/admin/useKillSwitchObservability.ts": { + "@typescript-eslint/no-explicit-any": 1 + }, + "src/hooks/admin/useSmokeTests.ts": { + "@typescript-eslint/no-explicit-any": 2 + }, "src/hooks/auth/useAccessSecurity.ts": { "@typescript-eslint/naming-convention": 5 }, @@ -491,6 +496,9 @@ "@typescript-eslint/naming-convention": 1, "@typescript-eslint/no-non-null-assertion": 1 }, + "src/lib/external-db/rest-native.ts": { + "@typescript-eslint/no-explicit-any": 3 + }, "src/lib/feature-flags.ts": { "@typescript-eslint/no-non-null-assertion": 1 }, @@ -529,6 +537,9 @@ "src/pages/admin/AdminExternalDbPage.tsx": { "react-hooks/exhaustive-deps": 1 }, + "src/pages/admin/ObservabilityDashboard.tsx": { + "eqeqeq": 1 + }, "src/pages/admin/PermissionsPage.tsx": { "react-hooks/exhaustive-deps": 1 }, diff --git a/src/components/admin/SellerDiscountLimitsPanel.tsx b/src/components/admin/SellerDiscountLimitsPanel.tsx index 6a756c4e1..35c9d7b86 100644 --- a/src/components/admin/SellerDiscountLimitsPanel.tsx +++ b/src/components/admin/SellerDiscountLimitsPanel.tsx @@ -1,15 +1,15 @@ /** * SellerDiscountLimitsPanel — gestão administrativa do limite máximo de desconto por vendedor. */ -import { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { supabase } from "@/integrations/supabase/client"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Percent, Save } from "lucide-react"; -import { toast } from "sonner"; +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Percent, Save } from 'lucide-react'; +import { toast } from 'sonner'; interface SellerRow { user_id: string; @@ -23,26 +23,30 @@ export function SellerDiscountLimitsPanel() { const [edits, setEdits] = useState>({}); const { data, isLoading } = useQuery({ - queryKey: ["seller-discount-limits"], + queryKey: ['seller-discount-limits'], queryFn: async (): Promise => { const { data: profiles, error: pErr } = await supabase - .from("profiles") - .select("user_id, full_name, email, role") - .eq("role", "vendedor"); + .from('profiles') + .select('user_id, full_name, email, role') + .eq('role', 'vendedor'); if (pErr) throw pErr; - const ids = (profiles || []).map((p) => p.user_id); + const sellers = (profiles || []).filter( + (p): p is typeof p & { user_id: string } => p.user_id !== null, + ); + const ids = sellers.map((p) => p.user_id); const { data: limits } = await supabase - .from("seller_discount_limits" as never) - .select("user_id, max_discount_percent") - .in("user_id", ids); + .from('seller_discount_limits' as never) + .select('user_id, max_discount_percent') + .in('user_id', ids); const byId = new Map( - ((limits as Array<{ user_id: string; max_discount_percent: number }>) || []).map( - (l) => [l.user_id, Number(l.max_discount_percent)] - ) + ((limits as Array<{ user_id: string; max_discount_percent: number }>) || []).map((l) => [ + l.user_id, + Number(l.max_discount_percent), + ]), ); - return (profiles || []).map((p) => ({ + return sellers.map((p) => ({ user_id: p.user_id, full_name: p.full_name, email: p.email, @@ -54,16 +58,19 @@ export function SellerDiscountLimitsPanel() { const save = useMutation({ mutationFn: async ({ userId, percent }: { userId: string; percent: number }) => { const { data: u } = await supabase.auth.getUser(); - if (!u.user) throw new Error("Não autenticado"); + if (!u.user) throw new Error('Não autenticado'); // eslint-disable-next-line @typescript-eslint/no-explicit-any const { error } = await (supabase as any) - .from("seller_discount_limits") - .upsert({ user_id: userId, max_discount_percent: percent, set_by: u.user.id }, { onConflict: "user_id" }); + .from('seller_discount_limits') + .upsert( + { user_id: userId, max_discount_percent: percent, set_by: u.user.id }, + { onConflict: 'user_id' }, + ); if (error) throw error; }, onSuccess: () => { - toast.success("Limite atualizado"); - qc.invalidateQueries({ queryKey: ["seller-discount-limits"] }); + toast.success('Limite atualizado'); + qc.invalidateQueries({ queryKey: ['seller-discount-limits'] }); }, onError: (e: Error) => toast.error(e.message), }); @@ -71,13 +78,17 @@ export function SellerDiscountLimitsPanel() { return ( - + Limites de desconto por vendedor {isLoading ? ( -
{[0, 1, 2].map((i) => )}
+
+ {[0, 1, 2].map((i) => ( + + ))} +
) : (
{(data ?? []).map((row) => { @@ -85,9 +96,9 @@ export function SellerDiscountLimitsPanel() { const dirty = current !== row.max_discount_percent; return (
-
-

{row.full_name || "Sem nome"}

-

{row.email}

+
+

{row.full_name || 'Sem nome'}

+

{row.email}

save.mutate({ userId: row.user_id, percent: current })} > - Salvar + Salvar
); diff --git a/src/components/admin/security/ActiveIpsList.tsx b/src/components/admin/security/ActiveIpsList.tsx index 7f04f7560..9c1b8b2b8 100644 --- a/src/components/admin/security/ActiveIpsList.tsx +++ b/src/components/admin/security/ActiveIpsList.tsx @@ -38,7 +38,7 @@ interface IpEntry { reason: string | null; expires_at: string | null; created_at: string; - created_by: string; + created_by: string | null; } type Filter = 'all' | 'allow' | 'block' | 'active' | 'expired'; diff --git a/src/components/admin/security/RecentAuditTable.tsx b/src/components/admin/security/RecentAuditTable.tsx index a9472baad..d0b098d6b 100644 --- a/src/components/admin/security/RecentAuditTable.tsx +++ b/src/components/admin/security/RecentAuditTable.tsx @@ -1,15 +1,28 @@ -import { useEffect, useMemo, useState } from "react"; -import { supabase } from "@/integrations/supabase/client"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { History, RefreshCw, Eye } from "lucide-react"; -import { format } from "date-fns"; -import { ptBR } from "date-fns/locale"; +import { useEffect, useMemo, useState } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { History, RefreshCw, Eye } from 'lucide-react'; +import { format } from 'date-fns'; +import { ptBR } from 'date-fns/locale'; interface AuditEntry { id: string; @@ -23,35 +36,44 @@ interface AuditEntry { created_at: string; } -interface ProfileLite { user_id: string; full_name: string | null; email: string | null } +interface ProfileLite { + user_id: string; + full_name: string | null; + email: string | null; +} export function RecentAuditTable() { const [entries, setEntries] = useState([]); const [profiles, setProfiles] = useState>({}); const [loading, setLoading] = useState(true); - const [filterAction, setFilterAction] = useState("all"); - const [search, setSearch] = useState(""); + const [filterAction, setFilterAction] = useState('all'); + const [search, setSearch] = useState(''); const [selected, setSelected] = useState(null); const load = async () => { setLoading(true); const { data, error } = await supabase - .from("admin_audit_log") - .select("*") - .order("created_at", { ascending: false }) + .from('admin_audit_log') + .select('*') + .order('created_at', { ascending: false }) .limit(50); - if (error) { setLoading(false); return; } + if (error) { + setLoading(false); + return; + } const list = (data || []) as AuditEntry[]; setEntries(list); const ids = Array.from(new Set(list.map((e) => e.user_id))); if (ids.length > 0) { const { data: profs } = await supabase - .from("profiles") - .select("user_id, full_name, email") - .in("user_id", ids); + .from('profiles') + .select('user_id, full_name, email') + .in('user_id', ids); const map: Record = {}; - (profs || []).forEach((p) => { map[p.user_id] = p as ProfileLite; }); + (profs || []).forEach((p) => { + if (p.user_id) map[p.user_id] = p as ProfileLite; + }); setProfiles(map); } setLoading(false); @@ -70,14 +92,21 @@ export function RecentAuditTable() { const filtered = useMemo(() => { return entries.filter((e) => { - if (filterAction !== "all" && e.action !== filterAction) return false; + if (filterAction !== 'all' && e.action !== filterAction) return false; if (search) { const q = search.toLowerCase(); - const adminName = (profiles[e.user_id]?.full_name || profiles[e.user_id]?.email || "").toLowerCase(); - if (!e.action.toLowerCase().includes(q) && - !e.resource_type.toLowerCase().includes(q) && - !(e.resource_id || "").toLowerCase().includes(q) && - !adminName.includes(q)) return false; + const adminName = ( + profiles[e.user_id]?.full_name || + profiles[e.user_id]?.email || + '' + ).toLowerCase(); + if ( + !e.action.toLowerCase().includes(q) && + !e.resource_type.toLowerCase().includes(q) && + !(e.resource_id || '').toLowerCase().includes(q) && + !adminName.includes(q) + ) + return false; } return true; }); @@ -87,11 +116,15 @@ export function RecentAuditTable() {
- Auditoria recente (50 últimas) - Ações administrativas registradas em admin_audit_log — atualiza a cada 30s + + Auditoria recente (50 últimas) + + + Ações administrativas registradas em admin_audit_log — atualiza a cada 30s +
@@ -104,10 +137,16 @@ export function RecentAuditTable() { className="max-w-xs" />
@@ -126,32 +165,56 @@ export function RecentAuditTable() { {filtered.length === 0 ? ( - Nenhuma entrada - ) : filtered.map((e) => { - const prof = profiles[e.user_id]; - return ( - - - {format(new Date(e.created_at), "dd/MM HH:mm:ss", { locale: ptBR })} - - -
{prof?.full_name || "—"}
-
{prof?.email || e.user_id.slice(0, 8)}
-
- {e.action} - -
{e.resource_type}
- {e.resource_id &&
{e.resource_id}
} -
- {e.ip_address || "—"} - - - -
- ); - })} + + + Nenhuma entrada + + + ) : ( + filtered.map((e) => { + const prof = profiles[e.user_id]; + return ( + + + {format(new Date(e.created_at), 'dd/MM HH:mm:ss', { locale: ptBR })} + + +
{prof?.full_name || '—'}
+
+ {prof?.email || e.user_id.slice(0, 8)} +
+
+ + + {e.action} + + + +
{e.resource_type}
+ {e.resource_id && ( +
+ {e.resource_id} +
+ )} +
+ {e.ip_address || '—'} + + + +
+ ); + }) + )}
@@ -165,16 +228,38 @@ export function RecentAuditTable() { {selected && (
-
{selected.action}
-
{selected.resource_type}
-
{selected.resource_id || "—"}
-
{format(new Date(selected.created_at), "dd/MM/yyyy HH:mm:ss", { locale: ptBR })}
-
{selected.ip_address || "—"}
-
{selected.user_agent || "—"}
+
+ +
{selected.action}
+
+
+ +
{selected.resource_type}
+
+
+ +
{selected.resource_id || '—'}
+
+
+ +
+ {format(new Date(selected.created_at), 'dd/MM/yyyy HH:mm:ss', { locale: ptBR })} +
+
+
+ +
{selected.ip_address || '—'}
+
+
+ +
+ {selected.user_agent || '—'} +
+
-
+                
                   {JSON.stringify(selected.details, null, 2)}
                 
@@ -187,5 +272,9 @@ export function RecentAuditTable() { } function Label({ children }: { children: React.ReactNode }) { - return
{children}
; + return ( +
+ {children} +
+ ); } diff --git a/src/components/admin/security/keys/audit/useMcpAuditFeed.ts b/src/components/admin/security/keys/audit/useMcpAuditFeed.ts index 0867ec0e8..4d2d53f05 100644 --- a/src/components/admin/security/keys/audit/useMcpAuditFeed.ts +++ b/src/components/admin/security/keys/audit/useMcpAuditFeed.ts @@ -13,7 +13,6 @@ export type AuditAction = | 'mcp_key.revoked' | 'mcp_key.scope_escalated' | 'mcp_key.auto_revoked' - | 'mcp_key.issue_denied' | 'mcp_key.issue_error' | 'mcp_key.revoke_denied' @@ -101,15 +100,17 @@ export function useMcpAuditFeed() { .select('user_id, email, full_name') .in('user_id', userIds); (profs ?? []).forEach( - (p: { user_id: string; email?: string | null; full_name?: string | null }) => { - profiles[p.user_id] = { email: p.email, full_name: p.full_name }; + (p: { user_id: string | null; email?: string | null; full_name?: string | null }) => { + if (p.user_id) profiles[p.user_id] = { email: p.email, full_name: p.full_name }; }, ); } const enriched = base.map((r) => { const d = (r.details ?? {}) as Record; - const scopes = (d.scopes ?? (d.after as Record)?.['scopes'] ?? []) as string[]; + const scopes = (d.scopes ?? + (d.after as Record)?.['scopes'] ?? + []) as string[]; const isFull = (Array.isArray(scopes) && scopes.includes('*')) || d.is_full_access === true; const escalated = d.escalated_to_full === true || r.action === 'mcp_key.scope_escalated'; const prof = r.user_id ? profiles[r.user_id] : undefined; diff --git a/src/components/admin/security/keys/audit/useStepUpAttempts.ts b/src/components/admin/security/keys/audit/useStepUpAttempts.ts index a4e1438ad..7b94e196c 100644 --- a/src/components/admin/security/keys/audit/useStepUpAttempts.ts +++ b/src/components/admin/security/keys/audit/useStepUpAttempts.ts @@ -4,10 +4,10 @@ * details.reason ∈ {step_up_required, step_up_invalid}. Suporta filtros por * usuário (email/nome/uid) e por chave (resource_id). */ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { supabase } from "@/integrations/supabase/client"; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { supabase } from '@/integrations/supabase/client'; -export type StepUpReason = "step_up_required" | "step_up_invalid"; +export type StepUpReason = 'step_up_required' | 'step_up_invalid'; export interface StepUpAttemptRow { id: string; @@ -32,41 +32,39 @@ export interface StepUpAttemptRow { } export interface StepUpFilters { - reason: "all" | StepUpReason; + reason: 'all' | StepUpReason; userQuery: string; keyId: string; fromDate?: string; } -const REASONS: StepUpReason[] = ["step_up_required", "step_up_invalid"]; -const ACTIONS = [ - "mcp_key.issue_denied", - "mcp_key.rotate_denied", - "mcp_key.update_denied", -]; +const REASONS: StepUpReason[] = ['step_up_required', 'step_up_invalid']; +const ACTIONS = ['mcp_key.issue_denied', 'mcp_key.rotate_denied', 'mcp_key.update_denied']; export function useStepUpAttempts() { const [allRows, setAllRows] = useState([]); const [loading, setLoading] = useState(true); const [filters, setFilters] = useState({ - reason: "all", - userQuery: "", - keyId: "", + reason: 'all', + userQuery: '', + keyId: '', }); const load = useCallback(async () => { setLoading(true); let q = supabase - .from("admin_audit_log") - .select("id, action, user_id, resource_id, ip_address, user_agent, details, created_at, request_id, source, status") - .eq("resource_type", "mcp_api_key") - .eq("status", "denied") - .in("action", ACTIONS) - .order("created_at", { ascending: false }) + .from('admin_audit_log') + .select( + 'id, action, user_id, resource_id, ip_address, user_agent, details, created_at, request_id, source, status', + ) + .eq('resource_type', 'mcp_api_key') + .eq('status', 'denied') + .in('action', ACTIONS) + .order('created_at', { ascending: false }) .limit(200); - if (filters.fromDate) q = q.gte("created_at", filters.fromDate); - if (filters.keyId) q = q.eq("resource_id", filters.keyId); + if (filters.fromDate) q = q.gte('created_at', filters.fromDate); + if (filters.keyId) q = q.eq('resource_id', filters.keyId); const { data, error } = await q; if (error) { @@ -75,28 +73,40 @@ export function useStepUpAttempts() { return; } - const base = (data ?? []) as Array>; + const base = (data ?? []) as Array>; // Filtra por reason no cliente (json field) const onlyStepUp = base.filter((r) => { const reason = (r.details as Record | null)?.reason as string | undefined; return reason && REASONS.includes(reason as StepUpReason); }); - const userIds = Array.from(new Set(onlyStepUp.map((r) => r.user_id).filter(Boolean))) as string[]; - const keyIds = Array.from(new Set(onlyStepUp.map((r) => r.resource_id).filter(Boolean))) as string[]; + const userIds = Array.from( + new Set(onlyStepUp.map((r) => r.user_id).filter(Boolean)), + ) as string[]; + const keyIds = Array.from( + new Set(onlyStepUp.map((r) => r.resource_id).filter(Boolean)), + ) as string[]; const [profilesRes, keysRes] = await Promise.all([ userIds.length - ? supabase.from("profiles").select("user_id, email, full_name").in("user_id", userIds) - : Promise.resolve({ data: [] as Array<{ user_id: string; email?: string | null; full_name?: string | null }> }), + ? supabase.from('profiles').select('user_id, email, full_name').in('user_id', userIds) + : Promise.resolve({ + data: [] as Array<{ + user_id: string; + email?: string | null; + full_name?: string | null; + }>, + }), keyIds.length - ? supabase.from("mcp_api_keys").select("id, name, key_prefix").in("id", keyIds) - : Promise.resolve({ data: [] as Array<{ id: string; name?: string | null; key_prefix?: string | null }> }), + ? supabase.from('mcp_api_keys').select('id, name, key_prefix').in('id', keyIds) + : Promise.resolve({ + data: [] as Array<{ id: string; name?: string | null; key_prefix?: string | null }>, + }), ]); const profiles: Record = {}; (profilesRes.data ?? []).forEach((p) => { - profiles[p.user_id] = { email: p.email, full_name: p.full_name }; + if (p.user_id) profiles[p.user_id] = { email: p.email, full_name: p.full_name }; }); const keys: Record = {}; (keysRes.data ?? []).forEach((k) => { @@ -109,7 +119,7 @@ export function useStepUpAttempts() { const key = r.resource_id ? keys[r.resource_id] : undefined; return { ...r, - reason: String(d.reason ?? "") as StepUpReason, + reason: String(d.reason ?? '') as StepUpReason, scope: (d.scope as string | undefined) ?? null, detail: (d.detail as string | undefined) ?? null, expected_action: (d.expected_action as string | undefined) ?? null, @@ -124,28 +134,33 @@ export function useStepUpAttempts() { setLoading(false); }, [filters.fromDate, filters.keyId]); - useEffect(() => { load(); }, [load]); + useEffect(() => { + load(); + }, [load]); const rows = useMemo(() => { return allRows.filter((r) => { - if (filters.reason !== "all" && r.reason !== filters.reason) return false; + if (filters.reason !== 'all' && r.reason !== filters.reason) return false; if (filters.userQuery) { const q = filters.userQuery.toLowerCase(); - const hay = `${r.actor_email ?? ""} ${r.actor_name ?? ""} ${r.user_id ?? ""}`.toLowerCase(); + const hay = `${r.actor_email ?? ''} ${r.actor_name ?? ''} ${r.user_id ?? ''}`.toLowerCase(); if (!hay.includes(q)) return false; } return true; }); }, [allRows, filters.reason, filters.userQuery]); - const counts = useMemo(() => ({ - total: allRows.length, - required: allRows.filter((r) => r.reason === "step_up_required").length, - invalid: allRows.filter((r) => r.reason === "step_up_invalid").length, - issue: allRows.filter((r) => r.action === "mcp_key.issue_denied").length, - rotate: allRows.filter((r) => r.action === "mcp_key.rotate_denied").length, - update: allRows.filter((r) => r.action === "mcp_key.update_denied").length, - }), [allRows]); + const counts = useMemo( + () => ({ + total: allRows.length, + required: allRows.filter((r) => r.reason === 'step_up_required').length, + invalid: allRows.filter((r) => r.reason === 'step_up_invalid').length, + issue: allRows.filter((r) => r.action === 'mcp_key.issue_denied').length, + rotate: allRows.filter((r) => r.action === 'mcp_key.rotate_denied').length, + update: allRows.filter((r) => r.action === 'mcp_key.update_denied').length, + }), + [allRows], + ); return { rows, allRows, loading, filters, setFilters, counts, reload: load }; } diff --git a/src/components/admin/security/keys/useMcpKeys.ts b/src/components/admin/security/keys/useMcpKeys.ts index 88f94a2f8..b28a8f28c 100644 --- a/src/components/admin/security/keys/useMcpKeys.ts +++ b/src/components/admin/security/keys/useMcpKeys.ts @@ -4,14 +4,14 @@ * Reusa o cliente Supabase autenticado (admin via RLS) e enriquece cada * chave com `creator_email` / `creator_name` via lookup batch em `profiles`. */ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { supabase } from "@/integrations/supabase/client"; -import { toast } from "sonner"; -import { isFullAccess } from "@/lib/mcp/scopes"; -import { sanitizeError } from "@/lib/security/sanitize-error"; -import { useDevChallenge } from "@/contexts/DevChallengeContext"; -import { handleStepUpError } from "@/lib/auth/step-up-error"; -import { createClientLogger } from "@/lib/telemetry/structuredLogger"; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { toast } from 'sonner'; +import { isFullAccess } from '@/lib/mcp/scopes'; +import { sanitizeError } from '@/lib/security/sanitize-error'; +import { useDevChallenge } from '@/contexts/DevChallengeContext'; +import { handleStepUpError } from '@/lib/auth/step-up-error'; +import { createClientLogger } from '@/lib/telemetry/structuredLogger'; export interface McpKeyRow { id: string; @@ -28,12 +28,12 @@ export interface McpKeyRow { // enriched creator_email: string | null; creator_name: string | null; - status: "active" | "expired" | "revoked"; + status: 'active' | 'expired' | 'revoked'; is_full: boolean; } -export type StatusFilter = "all" | "active" | "expired" | "revoked"; -export type SortKey = "created_desc" | "expires_asc" | "last_used_desc"; +export type StatusFilter = 'all' | 'active' | 'expired' | 'revoked'; +export type SortKey = 'created_desc' | 'expires_asc' | 'last_used_desc'; export interface CreatorOption { user_id: string; @@ -55,10 +55,13 @@ interface Filters { createdTo: string | null; } -function deriveStatus(row: { revoked_at: string | null; expires_at: string | null }): McpKeyRow["status"] { - if (row.revoked_at) return "revoked"; - if (row.expires_at && new Date(row.expires_at).getTime() <= Date.now()) return "expired"; - return "active"; +function deriveStatus(row: { + revoked_at: string | null; + expires_at: string | null; +}): McpKeyRow['status'] { + if (row.revoked_at) return 'revoked'; + if (row.expires_at && new Date(row.expires_at).getTime() <= Date.now()) return 'expired'; + return 'active'; } export function useMcpKeys() { @@ -66,10 +69,10 @@ export function useMcpKeys() { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [filters, setFilters] = useState({ - search: "", - status: "all", + search: '', + status: 'all', onlyFull: false, - sort: "created_desc", + sort: 'created_desc', creator: null, createdFrom: null, createdTo: null, @@ -78,14 +81,14 @@ export function useMcpKeys() { const load = useCallback(async () => { setLoading(true); const { data: keys, error } = await supabase - .from("mcp_api_keys") + .from('mcp_api_keys') .select( - "id, name, key_prefix, scopes, description, created_by, last_used_at, expires_at, revoked_at, created_at, rotated_from", + 'id, name, key_prefix, scopes, description, created_by, last_used_at, expires_at, revoked_at, created_at, rotated_from', ) - .order("created_at", { ascending: false }); + .order('created_at', { ascending: false }); if (error) { - toast.error("Erro ao carregar chaves", { description: error.message }); + toast.error('Erro ao carregar chaves', { description: error.message }); setRows([]); setLoading(false); return; @@ -95,16 +98,16 @@ export function useMcpKeys() { let creators: Map = new Map(); if (ids.length > 0) { const { data: profiles } = await supabase - .from("profiles") - .select("user_id, email, full_name") - .in("user_id", ids); + .from('profiles') + .select('user_id, email, full_name') + .in('user_id', ids); creators = new Map( - (profiles ?? []).map( - (p: { user_id: string; email: string | null; full_name: string | null }) => [ - p.user_id, - { email: p.email, name: p.full_name }, - ], - ), + (profiles ?? []) + .filter( + (p): p is { user_id: string; email: string | null; full_name: string | null } => + p.user_id !== null, + ) + .map((p) => [p.user_id, { email: p.email, name: p.full_name }]), ); } @@ -136,10 +139,10 @@ export function useMcpKeys() { (r) => r.name.toLowerCase().includes(q) || r.key_prefix.toLowerCase().includes(q) || - (r.creator_email ?? "").toLowerCase().includes(q), + (r.creator_email ?? '').toLowerCase().includes(q), ); } - if (filters.status !== "all") { + if (filters.status !== 'all') { out = out.filter((r) => r.status === filters.status); } if (filters.onlyFull) { @@ -164,14 +167,14 @@ export function useMcpKeys() { } const sorted = [...out]; switch (filters.sort) { - case "expires_asc": + case 'expires_asc': sorted.sort((a, b) => { const ax = a.expires_at ? new Date(a.expires_at).getTime() : Number.POSITIVE_INFINITY; const bx = b.expires_at ? new Date(b.expires_at).getTime() : Number.POSITIVE_INFINITY; return ax - bx; }); break; - case "last_used_desc": + case 'last_used_desc': sorted.sort((a, b) => { const ax = a.last_used_at ? new Date(a.last_used_at).getTime() : 0; const bx = b.last_used_at ? new Date(b.last_used_at).getTime() : 0; @@ -179,9 +182,7 @@ export function useMcpKeys() { }); break; default: - sorted.sort( - (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), - ); + sorted.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); } return sorted; }, [rows, filters]); @@ -213,10 +214,10 @@ export function useMcpKeys() { const counts = useMemo( () => ({ total: rows.length, - active: rows.filter((r) => r.status === "active").length, - expired: rows.filter((r) => r.status === "expired").length, - revoked: rows.filter((r) => r.status === "revoked").length, - full: rows.filter((r) => r.is_full && r.status === "active").length, + active: rows.filter((r) => r.status === 'active').length, + expired: rows.filter((r) => r.status === 'expired').length, + revoked: rows.filter((r) => r.status === 'revoked').length, + full: rows.filter((r) => r.is_full && r.status === 'active').length, }), [rows], ); @@ -229,35 +230,40 @@ export function useMcpKeys() { const requestStepUp = () => challenge({ - action: "mcp_key_revoke", - actionLabel: "Revogar chave MCP", + action: 'mcp_key_revoke', + actionLabel: 'Revogar chave MCP', targetRef: id, }); const token = await requestStepUp(); - if (!token) { log.warn('cancelled_by_user'); return false; } + if (!token) { + log.warn('cancelled_by_user'); + return false; + } const attempt = async (tk: string): Promise => { - const { data, error } = await supabase.functions.invoke("mcp-keys-revoke", { + const { data, error } = await supabase.functions.invoke('mcp-keys-revoke', { body: { key_id: id, reason: reason ?? null, step_up_token: tk }, headers: log.headers(), }); // Tratamento dedicado de step-up: mostra toast com CTA "Refazer verificação". - if (handleStepUpError(data, error, () => { - void (async () => { - const fresh = await requestStepUp(); - if (fresh) await attempt(fresh); - })(); - })) { + if ( + handleStepUpError(data, error, () => { + void (async () => { + const fresh = await requestStepUp(); + if (fresh) await attempt(fresh); + })(); + }) + ) { log.warn('step_up_required'); return false; } if (error || (data && (data as { error?: string }).error)) { log.error('failed', { err: error ?? data }); - toast.error("Erro ao revogar", { description: sanitizeError(error ?? data) }); + toast.error('Erro ao revogar', { description: sanitizeError(error ?? data) }); return false; } log.info('ok'); - toast.success("Chave revogada"); + toast.success('Chave revogada'); await load(); return true; }; @@ -267,5 +273,15 @@ export function useMcpKeys() { [load, challenge], ); - return { rows: filtered, allRows: rows, loading, filters, setFilters, counts, creators, reload: load, revoke }; + return { + rows: filtered, + allRows: rows, + loading, + filters, + setFilters, + counts, + creators, + reload: load, + revoke, + }; } diff --git a/src/components/admin/security/role-migration/RoleMigrationPanel.tsx b/src/components/admin/security/role-migration/RoleMigrationPanel.tsx index a07d8c2b5..9d1adc4ee 100644 --- a/src/components/admin/security/role-migration/RoleMigrationPanel.tsx +++ b/src/components/admin/security/role-migration/RoleMigrationPanel.tsx @@ -13,28 +13,56 @@ * RBAC: a RPC server-side exige `is_admin_strict`; aqui no front também * gateamos por `useUserRole` para evitar mostrar a UI a quem não pode usar. */ -import { useEffect, useMemo, useState } from "react"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Badge } from "@/components/ui/badge"; -import { Separator } from "@/components/ui/separator"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Loader2, PlayCircle, FlaskConical, History, ChevronRight, Users, AlertTriangle } from "lucide-react"; -import { supabase } from "@/integrations/supabase/client"; -import { toast } from "sonner"; -import { useRoleMigration, type AppRole, type BatchRow, type ItemRow, type MigrationItemInput } from "@/hooks/admin"; +import { useEffect, useMemo, useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Loader2, + PlayCircle, + FlaskConical, + History, + ChevronRight, + Users, + AlertTriangle, +} from 'lucide-react'; +import { supabase } from '@/integrations/supabase/client'; +import { toast } from 'sonner'; +import { + useRoleMigration, + type AppRole, + type BatchRow, + type ItemRow, + type MigrationItemInput, +} from '@/hooks/admin'; -const ROLES: AppRole[] = ["admin", "manager", "supervisor", "vendedor", "dev"]; +const ROLES: AppRole[] = ['admin', 'manager', 'supervisor', 'vendedor', 'dev']; const OPERATIONS = [ - { value: "add", label: "Adicionar papel", description: "INSERT user_roles (mantém os demais)" }, - { value: "remove", label: "Remover papel", description: "DELETE user_roles deste papel específico" }, - { value: "replace", label: "Substituir todos", description: "DELETE todos os papéis + INSERT do novo" }, + { value: 'add', label: 'Adicionar papel', description: 'INSERT user_roles (mantém os demais)' }, + { + value: 'remove', + label: 'Remover papel', + description: 'DELETE user_roles deste papel específico', + }, + { + value: 'replace', + label: 'Substituir todos', + description: 'DELETE todos os papéis + INSERT do novo', + }, ] as const; interface ProfileLite { @@ -44,27 +72,31 @@ interface ProfileLite { current_roles: AppRole[]; } -const STATUS_BADGE: Record = { - completed: { variant: "default", className: "bg-emerald-600 hover:bg-emerald-600" }, - partial: { variant: "secondary" }, - failed: { variant: "destructive" }, - running: { variant: "outline" }, - pending: { variant: "outline" }, - dry_run: { variant: "secondary" }, - success: { variant: "default", className: "bg-emerald-600 hover:bg-emerald-600" }, - skipped: { variant: "outline" }, +const STATUS_BADGE: Record< + string, + { variant: 'default' | 'destructive' | 'secondary' | 'outline'; className?: string } +> = { + completed: { variant: 'default', className: 'bg-emerald-600 hover:bg-emerald-600' }, + partial: { variant: 'secondary' }, + failed: { variant: 'destructive' }, + running: { variant: 'outline' }, + pending: { variant: 'outline' }, + dry_run: { variant: 'secondary' }, + success: { variant: 'default', className: 'bg-emerald-600 hover:bg-emerald-600' }, + skipped: { variant: 'outline' }, }; export function RoleMigrationPanel() { - const { batches, loadingBatches, submitting, refreshBatches, executeBatch, fetchItems } = useRoleMigration(); + const { batches, loadingBatches, submitting, refreshBatches, executeBatch, fetchItems } = + useRoleMigration(); const [profiles, setProfiles] = useState([]); const [loadingProfiles, setLoadingProfiles] = useState(false); - const [search, setSearch] = useState(""); + const [search, setSearch] = useState(''); const [selected, setSelected] = useState>(new Set()); - const [toRole, setToRole] = useState("supervisor"); - const [operation, setOperation] = useState("add"); - const [label, setLabel] = useState(""); - const [reason, setReason] = useState(""); + const [toRole, setToRole] = useState('supervisor'); + const [operation, setOperation] = useState('add'); + const [label, setLabel] = useState(''); + const [reason, setReason] = useState(''); const [openBatch, setOpenBatch] = useState(null); const [openItems, setOpenItems] = useState([]); const [loadingItems, setLoadingItems] = useState(false); @@ -76,8 +108,8 @@ export function RoleMigrationPanel() { setLoadingProfiles(true); try { const [{ data: profs, error: pErr }, { data: roles, error: rErr }] = await Promise.all([ - supabase.from("profiles").select("user_id, email, full_name").order("email"), - supabase.from("user_roles").select("user_id, role"), + supabase.from('profiles').select('user_id, email, full_name').order('email'), + supabase.from('user_roles').select('user_id, role'), ]); if (pErr) throw pErr; if (rErr) throw rErr; @@ -89,34 +121,42 @@ export function RoleMigrationPanel() { rolesByUser.set(r.user_id, arr); } setProfiles( - (profs ?? []).map((p) => ({ - user_id: p.user_id, - email: p.email, - full_name: p.full_name, - current_roles: rolesByUser.get(p.user_id) ?? [], - })), + (profs ?? []) + .filter((p): p is typeof p & { user_id: string } => p.user_id !== null) + .map((p) => ({ + user_id: p.user_id, + email: p.email, + full_name: p.full_name, + current_roles: rolesByUser.get(p.user_id) ?? [], + })), ); } catch (e) { - toast.error("Falha ao carregar usuários", { description: e instanceof Error ? e.message : String(e) }); + toast.error('Falha ao carregar usuários', { + description: e instanceof Error ? e.message : String(e), + }); } finally { if (!cancelled) setLoadingProfiles(false); } })(); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, []); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); if (!q) return profiles; - return profiles.filter((p) => - (p.email ?? "").toLowerCase().includes(q) || - (p.full_name ?? "").toLowerCase().includes(q)); + return profiles.filter( + (p) => + (p.email ?? '').toLowerCase().includes(q) || (p.full_name ?? '').toLowerCase().includes(q), + ); }, [profiles, search]); const toggle = (userId: string) => { setSelected((prev) => { const next = new Set(prev); - if (next.has(userId)) next.delete(userId); else next.add(userId); + if (next.has(userId)) next.delete(userId); + else next.add(userId); return next; }); }; @@ -126,15 +166,15 @@ export function RoleMigrationPanel() { const submit = async (dryRun: boolean) => { if (selected.size === 0) { - toast.error("Selecione ao menos 1 usuário"); + toast.error('Selecione ao menos 1 usuário'); return; } if (label.trim().length < 3) { - toast.error("Defina um rótulo descritivo para o lote (mín. 3 caracteres)"); + toast.error('Defina um rótulo descritivo para o lote (mín. 3 caracteres)'); return; } if (reason.trim().length < 5) { - toast.error("Justifique a migração (mín. 5 caracteres)"); + toast.error('Justifique a migração (mín. 5 caracteres)'); return; } try { @@ -144,14 +184,16 @@ export function RoleMigrationPanel() { items: buildItems(), dryRun, }); - toast.success(dryRun ? "Dry-run concluído" : "Lote executado", { + toast.success(dryRun ? 'Dry-run concluído' : 'Lote executado', { description: `Batch ${batchId.slice(0, 8)}… registrado.`, }); if (!dryRun) { setSelected(new Set()); } } catch (e) { - toast.error("Falha ao executar lote", { description: e instanceof Error ? e.message : String(e) }); + toast.error('Falha ao executar lote', { + description: e instanceof Error ? e.message : String(e), + }); } }; @@ -161,7 +203,9 @@ export function RoleMigrationPanel() { try { setOpenItems(await fetchItems(b.id)); } catch (e) { - toast.error("Falha ao carregar itens", { description: e instanceof Error ? e.message : String(e) }); + toast.error('Falha ao carregar itens', { + description: e instanceof Error ? e.message : String(e), + }); } finally { setLoadingItems(false); } @@ -172,13 +216,15 @@ export function RoleMigrationPanel() {
-
+
+ +
Migração de papéis em lote Execute trocas de papéis para múltiplos usuários com auditoria por evento - (`role_migration_items` + `admin_audit_log`). Sempre rode dry-run antes - de aplicar de verdade. + (`role_migration_items` + `admin_audit_log`). Sempre rode dry-run{' '} + antes de aplicar de verdade.
@@ -187,7 +233,12 @@ export function RoleMigrationPanel() {
- setLabel(e.target.value)} /> + setLabel(e.target.value)} + />
@@ -201,14 +252,21 @@ export function RoleMigrationPanel() {
- setOperation(v as MigrationItemInput['operation'])} + > + + + {OPERATIONS.map((o) => (
{o.label} - {o.description} + + {o.description} +
))} @@ -218,25 +276,38 @@ export function RoleMigrationPanel() {
- setSearch(e.target.value)} /> + setSearch(e.target.value)} + />
- {(operation === "replace" || toRole === "admin" || toRole === "dev") && ( + {(operation === 'replace' || toRole === 'admin' || toRole === 'dev') && ( Operação de alto impacto - {operation === "replace" && "Esta operação remove TODOS os papéis atuais antes de inserir o novo. "} - {(toRole === "admin" || toRole === "dev") && `Promover a "${toRole}" concede acesso administrativo. `} + {operation === 'replace' && + 'Esta operação remove TODOS os papéis atuais antes de inserir o novo. '} + {(toRole === 'admin' || toRole === 'dev') && + `Promover a "${toRole}" concede acesso administrativo. `} Sempre execute dry-run primeiro e revise o histórico. @@ -248,32 +319,52 @@ export function RoleMigrationPanel() { {selected.size} de {filtered.length} usuários selecionados

- - +
- + {loadingProfiles ? ( -
- Carregando… +
+ Carregando…
) : (
{filtered.map((p) => ( -