@@ -172,11 +209,27 @@ export function FailedDeliveriesPanel() {
{totalPages > 1 && (
-
-
Página {page + 1} de {totalPages}
+
+
+ Página {page + 1} de {totalPages}
+
-
-
+
+
)}
diff --git a/src/components/admin/connections/SecretsManagerHealthPanel.tsx b/src/components/admin/connections/SecretsManagerHealthPanel.tsx
index 9809ac09a..d51220960 100644
--- a/src/components/admin/connections/SecretsManagerHealthPanel.tsx
+++ b/src/components/admin/connections/SecretsManagerHealthPanel.tsx
@@ -17,11 +17,11 @@
* - Não inicia carga de secrets sozinho — apenas reage ao hook compartilhado.
* - request_id é copiável para correlação com edge logs.
*/
-import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
-import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
Activity,
Clock,
@@ -33,49 +33,49 @@ import {
Zap,
CheckCircle2,
XCircle,
-} from "lucide-react";
-import { supabase } from "@/integrations/supabase/client";
-import { newRequestId, REQUEST_ID_HEADER } from "@/lib/telemetry/requestId";
+} from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { newRequestId, REQUEST_ID_HEADER } from '@/lib/telemetry/requestId';
import {
getSecretsManagerSamples,
recordSecretsManagerCall,
subscribeSecretsManagerCalls,
type SecretsManagerCallSample,
-} from "@/lib/telemetry/secretsManagerCallMetrics";
-import { useSecretsManager } from "@/hooks/admin";
-import { toast } from "sonner";
+} from '@/lib/telemetry/secretsManagerCallMetrics';
+import { useSecretsManager } from '@/hooks/admin';
+import { toast } from 'sonner';
const MAX_RECENT = 8;
function describeListError(code: string, message: string): { title: string; hint: string } {
switch (code) {
- case "unauthenticated":
+ case 'unauthenticated':
return {
- title: "Sessão expirada — secrets-manager retornou 401",
- hint: "Faça login novamente. O painel só consegue listar credenciais com sessão válida.",
+ title: 'Sessão expirada — secrets-manager retornou 401',
+ hint: 'Faça login novamente. O painel só consegue listar credenciais com sessão válida.',
};
- case "forbidden":
- case "permission_denied":
+ case 'forbidden':
+ case 'permission_denied':
return {
- title: "Sem permissão — secrets-manager retornou 403",
- hint: "Apenas administradores conseguem ler credenciais. Verifique o papel do usuário em user_roles.",
+ title: 'Sem permissão — secrets-manager retornou 403',
+ hint: 'Apenas administradores conseguem ler credenciais. Verifique o papel do usuário em user_roles.',
};
default:
return {
- title: "Falha ao ler do secrets-manager",
- hint: message || "Erro inesperado. Veja os logs da edge function para detalhes.",
+ title: 'Falha ao ler do secrets-manager',
+ hint: message || 'Erro inesperado. Veja os logs da edge function para detalhes.',
};
}
}
function formatTime(ts: number): string {
- return new Date(ts).toLocaleTimeString("pt-BR", { hour12: false });
+ return new Date(ts).toLocaleTimeString('pt-BR', { hour12: false });
}
-function copyToClipboard(text: string, label = "Copiado") {
+function copyToClipboard(text: string, label = 'Copiado') {
navigator.clipboard.writeText(text).then(
() => toast.success(label, { description: text }),
- () => toast.error("Não foi possível copiar"),
+ () => toast.error('Não foi possível copiar'),
);
}
@@ -107,7 +107,7 @@ export function SecretsManagerHealthPanel({ className }: { className?: string })
const inferredBoot = useMemo(() => {
for (let i = samples.length - 1; i >= 0; i--) {
const s = samples[i];
- if (s.action === "list" || s.action === "status") return s;
+ if (s.action === 'list' || s.action === 'status') return s;
}
return null;
}, [samples]);
@@ -142,22 +142,23 @@ export function SecretsManagerHealthPanel({ className }: { className?: string })
try {
// Action `status` é alias leve de `list`. Pedimos um nome inexistente
// para minimizar payload — só queremos validar o roundtrip.
- const { data, error } = await supabase.functions.invoke("secrets-manager", {
- body: { action: "status", names: [] as string[] },
+ const { data, error } = await supabase.functions.invoke('secrets-manager', {
+ body: { action: 'status', names: [] as string[] },
headers: { [REQUEST_ID_HEADER]: requestId },
});
const durationMs = Math.round(performance.now() - startedAt);
const ctx = (error as { context?: Response } | null)?.context;
const status = ctx?.status;
const ok = !error && !!data && (data as { ok?: boolean }).ok !== false;
- const errorMessage = error?.message
- ?? (data && (data as { ok?: boolean }).ok === false
+ const errorMessage =
+ error?.message ??
+ (data && (data as { ok?: boolean }).ok === false
? (data as { error?: { message?: string } }).error?.message
: undefined);
// Alimenta o mesmo buffer das chamadas reais para aparecer na lista.
recordSecretsManagerCall({
- action: "status",
+ action: 'status',
durationMs,
ok,
status,
@@ -166,14 +167,23 @@ export function SecretsManagerHealthPanel({ className }: { className?: string })
});
setLastBoot({ ok, durationMs, status, error: errorMessage, requestId, ts: Date.now() });
- if (ok) toast.success("secrets-manager respondeu", { description: `${durationMs}ms` });
- else toast.error("secrets-manager falhou", { description: errorMessage ?? `HTTP ${status ?? "?"}` });
+ if (ok) toast.success('secrets-manager respondeu', { description: `${durationMs}ms` });
+ else
+ toast.error('secrets-manager falhou', {
+ description: errorMessage ?? `HTTP ${status ?? '?'}`,
+ });
} catch (err) {
const durationMs = Math.round(performance.now() - startedAt);
- const message = err instanceof Error ? err.message : "Erro desconhecido";
- recordSecretsManagerCall({ action: "status", durationMs, ok: false, errorMessage: message, requestId });
+ const message = err instanceof Error ? err.message : 'Erro desconhecido';
+ recordSecretsManagerCall({
+ action: 'status',
+ durationMs,
+ ok: false,
+ errorMessage: message,
+ requestId,
+ });
setLastBoot({ ok: false, durationMs, error: message, requestId, ts: Date.now() });
- toast.error("Falha de rede ao chamar secrets-manager", { description: message });
+ toast.error('Falha de rede ao chamar secrets-manager', { description: message });
} finally {
setPinging(false);
}
@@ -185,7 +195,9 @@ export function SecretsManagerHealthPanel({ className }: { className?: string })
useEffect(() => {
if (!boot && !pinging) {
// Pequeno delay para não competir com o list() inicial da página.
- const t = window.setTimeout(() => { ping(); }, 1200);
+ const t = window.setTimeout(() => {
+ ping();
+ }, 1200);
return () => window.clearTimeout(t);
}
}, [boot, pinging, ping]);
@@ -195,17 +207,29 @@ export function SecretsManagerHealthPanel({ className }: { className?: string })
const totalCount = samples.length;
const bootBadge = !boot
- ? { label: "Sem heartbeat", cls: "border-muted-foreground/40 bg-muted/40 text-muted-foreground", Icon: Clock }
+ ? {
+ label: 'Sem heartbeat',
+ cls: 'border-muted-foreground/40 bg-muted/40 text-muted-foreground',
+ Icon: Clock,
+ }
: boot.ok
- ? { label: "Operacional", cls: "border-success/40 bg-success/10 text-success", Icon: CheckCircle2 }
- : { label: "Falhou", cls: "border-destructive/40 bg-destructive/10 text-destructive", Icon: XCircle };
+ ? {
+ label: 'Operacional',
+ cls: 'border-success/40 bg-success/10 text-success',
+ Icon: CheckCircle2,
+ }
+ : {
+ label: 'Falhou',
+ cls: 'border-destructive/40 bg-destructive/10 text-destructive',
+ Icon: XCircle,
+ };
return (
-
+
-
+
@@ -217,9 +241,13 @@ export function SecretsManagerHealthPanel({ className }: { className?: string })
@@ -227,12 +255,9 @@ export function SecretsManagerHealthPanel({ className }: { className?: string })
{/* Linha de status do boot */}
-
-
-
+
+
+
{bootBadge.label}
@@ -240,7 +265,7 @@ export function SecretsManagerHealthPanel({ className }: { className?: string })
{boot ? (
{boot.durationMs}ms
- {typeof boot.status === "number" && <> · HTTP {boot.status}>}
+ {typeof boot.status === 'number' && <> · HTTP {boot.status}>}
<> · às {formatTime(boot.ts)}>
) : (
@@ -250,8 +275,8 @@ export function SecretsManagerHealthPanel({ className }: { className?: string })
{boot?.requestId && (
{/* Erro de leitura corrente (do hook compartilhado) */}
- {listError && (() => {
- const { title, hint } = describeListError(listError.code, listError.message);
- return (
-
- {listError.code === "unauthenticated" || listError.code === "forbidden" || listError.code === "permission_denied" ? (
-
- ) : (
-
- )}
- {title}
-
- {hint}
-
- código: {listError.code}
-
-
-
- );
- })()}
+ {listError &&
+ (() => {
+ const { title, hint } = describeListError(listError.code, listError.message);
+ return (
+
+ {listError.code === 'unauthenticated' ||
+ listError.code === 'forbidden' ||
+ listError.code === 'permission_denied' ? (
+
+ ) : (
+
+ )}
+ {title}
+
+ {hint}
+
+ código: {listError.code}
+
+
+
+ );
+ })()}
{/* Últimas chamadas */}
-
+
- Últimas chamadas{" "}
+ Últimas chamadas{' '}
({recent.length}/{totalCount}
- {errorCount > 0 && · {errorCount} erro{errorCount === 1 ? "" : "s"}})
+ {errorCount > 0 && (
+
+ {' '}
+ · {errorCount} erro{errorCount === 1 ? '' : 's'}
+
+ )}
+ )
{recent.length === 0 ? (
-
+
Nenhuma chamada registrada ainda nesta sessão.
) : (
@@ -310,53 +344,54 @@ export function SecretsManagerHealthPanel({ className }: { className?: string })
}
function SampleRow({ sample }: { sample: SecretsManagerCallSample }) {
+ const requestId = sample.requestId;
const tone = sample.ok
- ? "border-success/30 bg-success/5"
- : "border-destructive/40 bg-destructive/5";
+ ? 'border-success/30 bg-success/5'
+ : 'border-destructive/40 bg-destructive/5';
return (
- {sample.ok ? "OK" : "ERR"}
+ {sample.ok ? 'OK' : 'ERR'}
{sample.action}
{sample.target && (
-
+
{sample.target}
)}
{sample.durationMs}ms
- {typeof sample.status === "number" && (
+ {typeof sample.status === 'number' && (
HTTP {sample.status}
)}
{formatTime(sample.ts)}
{!sample.ok && sample.errorMessage && (
-
+
· {sample.errorMessage}
)}
- {sample.requestId && (
+ {requestId && (
)}
diff --git a/src/components/admin/connections/SupabaseConnectionsTab.tsx b/src/components/admin/connections/SupabaseConnectionsTab.tsx
index d3e004ffe..2e233bd23 100644
--- a/src/components/admin/connections/SupabaseConnectionsTab.tsx
+++ b/src/components/admin/connections/SupabaseConnectionsTab.tsx
@@ -54,6 +54,13 @@ const ENVS = [
},
] as const;
+type SupabaseEnv = (typeof ENVS)[number];
+type ManagedSupabaseEnv = Extract
;
+
+function isManagedSupabaseEnv(env: SupabaseEnv): env is ManagedSupabaseEnv {
+ return !env.readOnly;
+}
+
export function SupabaseConnectionsTab() {
const { secrets, list, listError } = useSecretsManager();
const { test, isTesting, fetchLastTest } = useConnectionTester();
@@ -71,8 +78,8 @@ export function SupabaseConnectionsTab() {
const hydrate = useCallback(async () => {
const entries = await Promise.all(
- ENVS.filter((e) => e.envKey).map(async (e) => {
- const last = await fetchLastTest('supabase', { env_key: e.envKey! });
+ ENVS.filter(isManagedSupabaseEnv).map(async (e) => {
+ const last = await fetchLastTest('supabase', { env_key: e.envKey });
return [
e.key,
last
@@ -118,28 +125,30 @@ export function SupabaseConnectionsTab() {
return (
{ENVS.map((env) => {
- const url = env.urlSecret ? get(env.urlSecret) : undefined;
- const anon = env.anonSecret ? get(env.anonSecret) : undefined;
- const svc = env.serviceSecret ? get(env.serviceSecret) : undefined;
- const last = env.readOnly ? null : (lastByEnv[env.key] ?? null);
+ const isManaged = isManagedSupabaseEnv(env);
+ const url = isManaged ? get(env.urlSecret) : undefined;
+ const anon = isManaged ? get(env.anonSecret) : undefined;
+ const svc = isManaged ? get(env.serviceSecret) : undefined;
+ const last = isManaged ? (lastByEnv[env.key] ?? null) : null;
+ const pendingStartedAt = pendingByEnv[env.key];
const credsConfigured = !!url?.has_value && !!svc?.has_value;
- const suspicious = !env.readOnly
- ? hasSuspiciousLength(secrets, [env.urlSecret!, env.anonSecret!, env.serviceSecret!])
+ const suspicious = isManaged
+ ? hasSuspiciousLength(secrets, [env.urlSecret, env.anonSecret, env.serviceSecret])
: false;
const credsLooksValid = credsConfigured && !suspicious;
- const preflightIssues = !env.readOnly
+ const preflightIssues = isManaged
? getPreflightIssues(secrets, [
- { name: env.urlSecret!, label: 'URL do projeto' },
- { name: env.serviceSecret!, label: 'Service Role Key' },
+ { name: env.urlSecret, label: 'URL do projeto' },
+ { name: env.serviceSecret, label: 'Service Role Key' },
])
: [];
const status = resolveSupabaseConnectionStatus({
- readOnly: !!env.readOnly,
+ readOnly: !isManaged,
url,
service: svc,
last,
});
- const canTest = !env.readOnly && credsLooksValid && preflightIssues.length === 0;
+ const canTest = isManaged && credsLooksValid && preflightIssues.length === 0;
return (
{env.description}
- {env.readOnly ? (
+ {!isManaged ? (
Credenciais gerenciadas automaticamente. Não requer configuração manual.
@@ -174,21 +183,21 @@ export function SupabaseConnectionsTab() {
/>
handleTest(env.envKey!, env.key)}
+ onClick={() => handleTest(env.envKey, env.key)}
>
{isTesting ? 'Testando…' : 'Testar conexão'}
@@ -258,7 +267,7 @@ export function SupabaseConnectionsTab() {
}
action={
handleTest(env.envKey!, env.key)}
+ onRetest={() => handleTest(env.envKey, env.key)}
disabled={!canTest}
cooldownKey={`supabase:${env.envKey}`}
disabledReason={
@@ -273,19 +282,17 @@ export function SupabaseConnectionsTab() {
/>
setDetailsDialogByEnv((cur) => ({ ...cur, [env.key]: v }))}
connectionType="supabase"
connectionLabel={env.name}
- envKey={env.envKey!}
+ envKey={env.envKey}
onViewFullHistory={() =>
setTimelineOpenByEnv((cur) => ({ ...cur, [env.key]: true }))
}
@@ -298,11 +305,11 @@ export function SupabaseConnectionsTab() {
status={status}
last={last}
fields={[
- { label: 'URL do projeto', secretName: env.urlSecret!, status: url },
- { label: 'Anon Key', secretName: env.anonSecret!, status: anon },
+ { label: 'URL do projeto', secretName: env.urlSecret, status: url },
+ { label: 'Anon Key', secretName: env.anonSecret, status: anon },
{
label: 'Service Role Key',
- secretName: env.serviceSecret!,
+ secretName: env.serviceSecret,
status: svc,
sensitive: true,
},
diff --git a/src/components/admin/connections/__tests__/ConnectionUI.test.tsx b/src/components/admin/connections/__tests__/ConnectionUI.test.tsx
index d761bd3a2..6db866893 100644
--- a/src/components/admin/connections/__tests__/ConnectionUI.test.tsx
+++ b/src/components/admin/connections/__tests__/ConnectionUI.test.tsx
@@ -3,8 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ConnectionsOverviewTable } from '../ConnectionsOverviewTable';
import { TooltipProvider } from '@/components/ui/tooltip';
import { useAuth } from '@/contexts/AuthContext';
-import { useConnectionsOverview } from '@/hooks/intelligence';
-import { useConnectionTester } from '@/hooks/intelligence';
+import { useConnectionTester, useConnectionsOverview } from '@/hooks/intelligence';
// Mocks
vi.mock('@/contexts/AuthContext', () => ({
@@ -43,6 +42,10 @@ vi.mock('@/hooks/intelligence', () => ({
}));
describe('ConnectionsOverviewTable Interações e Acessibilidade', () => {
+ const useAuthMock = vi.mocked(useAuth);
+ const useConnectionsOverviewMock = vi.mocked(useConnectionsOverview);
+ const useConnectionTesterMock = vi.mocked(useConnectionTester);
+
const mockRows = [
{
id: '1',
@@ -57,13 +60,13 @@ describe('ConnectionsOverviewTable Interações e Acessibilidade', () => {
beforeEach(() => {
vi.clearAllMocks();
- (useAuth as any).mockReturnValue({ isAdmin: true });
- (useConnectionsOverview as any).mockReturnValue({
+ useAuthMock.mockReturnValue({ isAdmin: true });
+ useConnectionsOverviewMock.mockReturnValue({
rows: mockRows,
loading: false,
refresh: vi.fn(),
});
- (useConnectionTester as any).mockReturnValue({ test: vi.fn(), testing: false });
+ useConnectionTesterMock.mockReturnValue({ test: vi.fn(), testing: false });
});
it('deve permitir focar e navegar nos botões de ação via teclado', () => {
diff --git a/src/components/admin/connections/__tests__/ConnectionsOverviewTable.test.tsx b/src/components/admin/connections/__tests__/ConnectionsOverviewTable.test.tsx
index 171fc2fd8..4b7e16e65 100644
--- a/src/components/admin/connections/__tests__/ConnectionsOverviewTable.test.tsx
+++ b/src/components/admin/connections/__tests__/ConnectionsOverviewTable.test.tsx
@@ -2,8 +2,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ConnectionsOverviewTable } from '../ConnectionsOverviewTable';
import { useAuth } from '@/contexts/AuthContext';
-import { useConnectionsOverview } from '@/hooks/intelligence';
-import { useConnectionTester } from '@/hooks/intelligence';
+import { useConnectionTester, useConnectionsOverview } from '@/hooks/intelligence';
import { useConsecutiveFailures } from '@/hooks/common';
import { useSecretsManager } from '@/hooks/admin';
import { TooltipProvider } from '@/components/ui/tooltip';
@@ -38,6 +37,12 @@ vi.mock('@/hooks/intelligence', () => ({
}));
describe('ConnectionsOverviewTable Regression Tests', () => {
+ const useAuthMock = vi.mocked(useAuth);
+ const useConnectionsOverviewMock = vi.mocked(useConnectionsOverview);
+ const useConnectionTesterMock = vi.mocked(useConnectionTester);
+ const useConsecutiveFailuresMock = vi.mocked(useConsecutiveFailures);
+ const useSecretsManagerMock = vi.mocked(useSecretsManager);
+
const mockRows = [
{
key: 'conn-1',
@@ -69,23 +74,23 @@ describe('ConnectionsOverviewTable Regression Tests', () => {
beforeEach(() => {
vi.clearAllMocks();
- (useAuth as any).mockReturnValue({ isAdmin: true });
- (useConnectionsOverview as any).mockReturnValue({
+ useAuthMock.mockReturnValue({ isAdmin: true });
+ useConnectionsOverviewMock.mockReturnValue({
rows: mockRows,
loading: false,
refreshing: false,
refresh: vi.fn(),
patchRow: vi.fn(),
});
- (useConnectionTester as any).mockReturnValue({
+ useConnectionTesterMock.mockReturnValue({
test: vi.fn(),
testing: false,
});
- (useConsecutiveFailures as any).mockReturnValue({
+ useConsecutiveFailuresMock.mockReturnValue({
map: new Map(),
loading: false,
});
- (useSecretsManager as any).mockReturnValue({
+ useSecretsManagerMock.mockReturnValue({
secrets: [],
list: vi.fn(),
});
@@ -118,7 +123,7 @@ describe('ConnectionsOverviewTable Regression Tests', () => {
it('should trigger refresh when button is clicked', async () => {
const refreshMock = vi.fn();
- (useConnectionsOverview as any).mockReturnValue({
+ useConnectionsOverviewMock.mockReturnValue({
rows: mockRows,
loading: false,
refreshing: false,
@@ -139,7 +144,7 @@ describe('ConnectionsOverviewTable Regression Tests', () => {
});
it('should handle empty state', async () => {
- (useConnectionsOverview as any).mockReturnValue({
+ useConnectionsOverviewMock.mockReturnValue({
rows: [],
loading: false,
refreshing: false,
diff --git a/src/components/admin/connections/useSecretField.ts b/src/components/admin/connections/useSecretField.ts
index f3d4feb35..ef35f5e11 100644
--- a/src/components/admin/connections/useSecretField.ts
+++ b/src/components/admin/connections/useSecretField.ts
@@ -1,10 +1,6 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { toast } from 'sonner';
-import {
- useSecretsManager,
- type SecretStatus,
- type SecretMutationResult,
-} from '@/hooks/admin';
+import { useSecretsManager, type SecretStatus, type SecretMutationResult } from '@/hooks/admin';
import { normalizeSecret } from './secretNormalizers';
import { validateSecretName } from './secretWhitelist';
import { validateSecret, getMinLength, MIN_SUFFIX_LENGTH } from './secretValidators';
@@ -143,7 +139,7 @@ export function useSecretField({ secretName, status, connectionId, onSaved }: Us
} catch {
/* empty */
}
- }, [secretName, connectionId]);
+ }, [secretName, connectionId, draftScope, draftKey, legacyDraftKey]);
useEffect(() => {
if (editing && value.length > 0) {