From ae39797910a851affbecb42c8d3949f2f1b37b8e Mon Sep 17 00:00:00 2001 From: betoarts Date: Thu, 17 Sep 2026 09:37:59 -0300 Subject: [PATCH] Fix branding sync and AI credential safeguards --- .changes/limpeza-total-do-docker.md | 9 ++ README.md | 22 ++++ app/(public)/layout.tsx | 13 +- .../ai/credentials/[id]/revalidate/route.ts | 9 +- app/api/v1/marca/logo/route.ts | 78 ++++++++++-- app/api/v1/team/[user_id]/activate/route.ts | 35 ++++++ app/api/v1/team/route.ts | 1 - .../ai/agents/[id]/_components/TestPanel.tsx | 4 + .../ai/agents/_components/AgentRowMenu.tsx | 14 ++- .../_components/CredentialCard.tsx | 16 +-- .../_components/CredentialsList.tsx | 4 +- app/app/ai/credentials/page.tsx | 6 + app/app/settings/notifications/page.tsx | 9 +- .../team/_components/TeamMembersClient.tsx | 22 ++-- .../[token]/AutoAcceptInvite.tsx | 50 ++++++++ app/team/accept-invite/[token]/page.tsx | 21 +++- components/app/ConexaoCaidaBanner.tsx | 2 +- components/shell/Sidebar.tsx | 6 +- docker-compose.local.yml | 5 +- docker-compose.prod.yml | 3 +- hooks/team/useActivateMember.ts | 14 +++ lib/agent-engine/agent/inbound-turn.ts | 76 +++++++----- lib/ai/credenciais/guardar.ts | 8 +- lib/api/client.test.ts | 12 ++ lib/api/client.ts | 14 ++- scripts/local-env.sh | 17 +++ scripts/local-stack.sh | 15 +++ scripts/setup-web-push.sh | 20 ++++ ...configuracao_resend_e_ativacao_membros.sql | 26 ++++ tests/e2e/marca-logo.spec.ts | 18 ++- tests/unit/email-marca-e-remetente.test.ts | 113 ++++-------------- unistall_docker.sh | 100 ++++++++++++++++ 32 files changed, 581 insertions(+), 181 deletions(-) create mode 100644 .changes/limpeza-total-do-docker.md create mode 100644 app/api/v1/team/[user_id]/activate/route.ts create mode 100644 app/team/accept-invite/[token]/AutoAcceptInvite.tsx create mode 100644 hooks/team/useActivateMember.ts create mode 100755 scripts/setup-web-push.sh create mode 100644 supabase/migrations/20260911123411_configuracao_resend_e_ativacao_membros.sql create mode 100755 unistall_docker.sh diff --git a/.changes/limpeza-total-do-docker.md b/.changes/limpeza-total-do-docker.md new file mode 100644 index 000000000..b203acae6 --- /dev/null +++ b/.changes/limpeza-total-do-docker.md @@ -0,0 +1,9 @@ +--- +impacto: capacidade_nova +secao: adicionado +titulo: Utilitario para remover todos os recursos de um daemon Docker +--- + +Agora ha um script operacional que, mediante confirmacao explicita, para e remove +containers, imagens, volumes, redes personalizadas e cache do daemon Docker selecionado. +Use-o apenas quando a intencao for descartar todos os dados Docker da maquina. diff --git a/README.md b/README.md index 0ba10786b..597d5b831 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,28 @@ próxima seção para o ambiente local: `hostgator-setup-kit/install.sh` é dest Guia detalhado: [`docs/SETUP.md`](docs/SETUP.md#instalação-local-em-ubuntuvm-postgresql--serviços-em-docker). +### 🧨 Zerar todos os recursos Docker + +Se a intenção for descartar **tudo** que existe no daemon Docker atualmente selecionado +(não apenas os serviços deste projeto), use o script abaixo na raiz do repositório: + +```bash +bash unistall_docker.sh +``` + +Ele pede que você digite `ZERAR-DOCKER` antes de continuar e remove todos os containers, +imagens, volumes — inclusive bancos de dados —, redes personalizadas e cache de build. +É uma operação irreversível: faça backup antes se houver qualquer dado a preservar. + +Para execução não interativa, por exemplo em uma rotina de descarte de ambiente: + +```bash +bash unistall_docker.sh --force +``` + +O script não desinstala o Docker, não remove as redes internas padrão (`bridge`, `host` e +`none`) e não altera configurações do daemon ou credenciais locais. + --- ## ⚡ Instalar na sua VPS (o caminho principal) diff --git a/app/(public)/layout.tsx b/app/(public)/layout.tsx index 1c91d5f7b..685ce6282 100644 --- a/app/(public)/layout.tsx +++ b/app/(public)/layout.tsx @@ -52,24 +52,25 @@ export default async function PublicLayout({ children }: { children: React.React em vez de next/image pelo mesmo motivo da barra lateral: a URL é de quem hospeda e o `next/image` exige allowlist de domínios fechada em BUILD — a imagem pré-buildada do self-host recusaria o - domínio do operador. Altura fixa e largura livre para não distorcer - arte de proporção desconhecida. + domínio do operador. Medida fixa aprovada pela interface + (226×62px), com object-contain para não distorcer arte de + proporção desconhecida. O `alt` é o nome DESTA resolução (`marca.nome`), e não o de `branding()`: é a legenda da imagem que está ali, e nomeá-la com a marca de outra fonte descreveria uma marca que não é a do logo. O `data-testid` é lido por `tests/e2e/marca-logo.spec.ts`, que prova - que o logo da EMPRESA não vaza para cá. Sem ele a spec caía na - "primeira da página", e uma asserção de negação com seletor - largo passa sozinha assim que outra imagem entra na tela. + que o logo sincronizado da instalação aparece na fachada. Sem ele + a spec cairia na "primeira da página" e poderia medir outra + imagem assim que uma nova imagem entrasse na tela. */} {/* eslint-disable-next-line @next/next/no-img-element */} {marca.nome} )} diff --git a/app/api/v1/ai/credentials/[id]/revalidate/route.ts b/app/api/v1/ai/credentials/[id]/revalidate/route.ts index f7032d56a..a4ef5a1c4 100644 --- a/app/api/v1/ai/credentials/[id]/revalidate/route.ts +++ b/app/api/v1/ai/credentials/[id]/revalidate/route.ts @@ -14,6 +14,7 @@ import { audit } from "@/lib/audit"; import { requireRole } from "@/lib/auth/require-role"; import { byteaToBuffer, decryptKey } from "@/lib/crypto/aes_gcm"; import { validateProviderKey } from "@/lib/ai/provider-validators"; +import { logger } from "@/lib/logger"; import { createAdminClient } from "@/lib/supabase/admin"; import { traduzir } from "@/lib/i18n/dicionario"; @@ -69,7 +70,10 @@ export async function POST( tag: byteaToBuffer(row.api_key_tag), }); } catch (err) { - console.error("[ai.credentials] decrypt failed during revalidate", err); + logger.error("[ai.credentials] decifragem falhou durante a revalidação", { + credentialId: id, + erro: err instanceof Error ? err.name : typeof err, + }); return fail("decrypt_failed", t("Falha ao decifrar credential."), 500, { requestId }); } @@ -83,6 +87,9 @@ export async function POST( : { validated_at: null, validation_error: result.error, + // Não conservar o catálogo de uma validação anterior: a credencial + // deixou de ser confiável e a lista antiga faria a tela parecer pronta. + models_available: null, }; const { data: updated, error: updErr } = await admin diff --git a/app/api/v1/marca/logo/route.ts b/app/api/v1/marca/logo/route.ts index 3d8efc3ac..d5b781e06 100644 --- a/app/api/v1/marca/logo/route.ts +++ b/app/api/v1/marca/logo/route.ts @@ -12,9 +12,10 @@ import { requireSupportWrite } from "@/lib/impersonate/support"; * 1. **escopo por `z.enum`** — allowlist, nunca string livre. Sem ela, * `../platform` vira prefixo e o arquivo de um tenant aterrissa na pasta da * instalação. - * 2. **gate por escopo** — `platform_admin` para a instalação (é a marca do - * LOGIN, comum a todos os clientes do revendedor); `admin` do tenant + MFA - * em dia para a organização. Duplicado dos escritores que já existem + * 2. **gate por escopo** — `platform_admin` para a instalação; `admin` do tenant + * + MFA em dia para a organização. O logo da organização também é + * sincronizado com a fachada do login; nome e cor continuam escopados à + * organização. Duplicado dos escritores que já existem * (`updateBranding.ts`, `updateMarcaDaOrganizacao.ts`), de propósito. * 3. **rate limit** — depois do gate: quem nem passou no papel não gasta o * orçamento de quem passou. @@ -218,7 +219,11 @@ async function caminhoGravado(ctx: Contexto): Promise { * outros fazem read-modify-write do jsonb inteiro. O `row_count` de volta é o que * distingue "gravou" de "não gravou". */ -async function gravarCaminho(ctx: Contexto, caminho: string | null): Promise { +async function gravarCaminho( + ctx: Contexto, + caminho: string | null, + caminhoDaFachada: string | null = caminho, +): Promise { const admin = createAdminClient(); if (ctx.escopo === "instalacao") { const { error } = await admin @@ -278,6 +283,24 @@ async function gravarCaminho(ctx: Contexto, caminho: string | null): Promise { +async function apagarAnterior( + ctx: Contexto, + anterior: string | null, + prefixo = ctx.prefixo, +): Promise { if (!anterior) return; - if (!podeApagar(anterior, ctx.prefixo)) { + if (!podeApagar(anterior, prefixo)) { logger.error("[marca/logo] recusei apagar arquivo fora do escopo de quem pediu", { escopo: ctx.escopo, - prefixo_esperado: ctx.prefixo, + prefixo_esperado: prefixo, // O caminho INTEIRO, e não só o prefixo: sem ele o registro guarda a // mensagem e descarta o dono do erro — completo por fora, indiagnosticável // por dentro. O caminho não é segredo (o bucket é público) e é a única @@ -422,6 +449,14 @@ export async function POST(req: NextRequest): Promise { const anterior = await caminhoGravado(ctx); const caminho = caminhoNovoDoLogo(ctx.prefixo, extensaoDe(tipo)); + const anteriorDaFachada = + ctx.escopo === "organizacao" + ? await caminhoGravado({ escopo: "instalacao", userId: ctx.userId, prefixo: PREFIXO_DA_INSTALACAO }) + : anterior; + const caminhoDaFachada = + ctx.escopo === "organizacao" + ? caminhoNovoDoLogo(PREFIXO_DA_INSTALACAO, extensaoDe(tipo)) + : caminho; const { error: erroUp } = await createAdminClient() .storage.from(BUCKET_DE_LOGOS) @@ -431,16 +466,36 @@ export async function POST(req: NextRequest): Promise { return fail("internal_error", "Erro ao subir o logo.", 500, { requestId }); } - const recusa = await gravarCaminho(ctx, caminho); + if (caminhoDaFachada !== caminho) { + const { error: erroUpFachada } = await createAdminClient() + .storage.from(BUCKET_DE_LOGOS) + .upload(caminhoDaFachada, bytes, { contentType: tipo, upsert: false }); + if (erroUpFachada) { + logger.error("[marca/logo] upload da fachada falhou", { + detalhe: erroUpFachada.message, + requestId, + }); + void createAdminClient().storage.from(BUCKET_DE_LOGOS).remove([caminho]); + return fail("internal_error", "Erro ao subir o logo do login.", 500, { requestId }); + } + } + + const recusa = await gravarCaminho(ctx, caminho, caminhoDaFachada); if (recusa) { // A gravação falhou DEPOIS do upload: o arquivo novo é que vira órfão, não o // antigo. Tentar apagá-lo aqui seria o caminho certo e não é obrigatório — // por isso é `void`, sem `await` no caminho de erro. void createAdminClient().storage.from(BUCKET_DE_LOGOS).remove([caminho]); + if (caminhoDaFachada !== caminho) { + void createAdminClient().storage.from(BUCKET_DE_LOGOS).remove([caminhoDaFachada]); + } return fail(recusa.codigo, recusa.mensagem, recusa.status, { requestId }); } await apagarAnterior(ctx, anterior); + if (caminhoDaFachada !== caminho) { + await apagarAnterior(ctx, anteriorDaFachada, PREFIXO_DA_INSTALACAO); + } await registrarAuditoria(ctx, req, requestId, "definido"); return ok( @@ -486,10 +541,17 @@ export async function DELETE(req: NextRequest): Promise { } const anterior = await caminhoGravado(ctx); + const anteriorDaFachada = + ctx.escopo === "organizacao" + ? await caminhoGravado({ escopo: "instalacao", userId: ctx.userId, prefixo: PREFIXO_DA_INSTALACAO }) + : anterior; const recusa = await gravarCaminho(ctx, null); if (recusa) return fail(recusa.codigo, recusa.mensagem, recusa.status, { requestId }); await apagarAnterior(ctx, anterior); + if (ctx.escopo === "organizacao") { + await apagarAnterior(ctx, anteriorDaFachada, PREFIXO_DA_INSTALACAO); + } await registrarAuditoria(ctx, req, requestId, "removido"); return ok({ logo_path: null, logo_url: null }, { requestId }); diff --git a/app/api/v1/team/[user_id]/activate/route.ts b/app/api/v1/team/[user_id]/activate/route.ts new file mode 100644 index 000000000..31d2e6c14 --- /dev/null +++ b/app/api/v1/team/[user_id]/activate/route.ts @@ -0,0 +1,35 @@ +import { requireSupportWrite } from "@/lib/impersonate/support"; +import { randomUUID } from "node:crypto"; +import type { NextRequest } from "next/server"; +import { ok, fail } from "@/lib/api/wrappers"; +import { audit } from "@/lib/audit"; +import { requireRole } from "@/lib/auth/require-role"; +import { createClient } from "@/lib/supabase/server"; + +export const dynamic = "force-dynamic"; + +export async function POST(_req: NextRequest, ctx: { params: Promise<{ user_id: string }> }): Promise { + const supportDenied = await requireSupportWrite(); + if (supportDenied) return supportDenied; + const requestId = randomUUID(); + const { user_id: targetUserId } = await ctx.params; + const authz = await requireRole("admin", { requestId, resource: "team" }); + if (!authz.ok) return authz.response; + const { user: authUser, org } = authz; + const supabase = await createClient(); + const { data: target, error: fetchErr } = await supabase + .from("user_organizations") + .select("id, revoked_at") + .eq("organization_id", org.orgId) + .eq("user_id", targetUserId) + .maybeSingle(); + if (fetchErr) return fail("internal_error", fetchErr.message, 500, { requestId }); + if (!target) return fail("not_found", "Membro não encontrado.", 404, { requestId }); + if (!target.revoked_at) return ok({ user_id: targetUserId, already_active: true }, { requestId }); + const now = new Date().toISOString(); + const { error } = await supabase.from("user_organizations").update({ revoked_at: null, updated_at: now }).eq("id", target.id); + if (error) return fail("internal_error", error.message, 500, { requestId }); + await audit({ action: "member.activated", actorUserId: authUser.id, organizationId: org.orgId, resourceType: "membership", resourceId: target.id, requestId, metadata: { target_user_id: targetUserId } }); + return ok({ user_id: targetUserId, activated_at: now }, { requestId }); +} + diff --git a/app/api/v1/team/route.ts b/app/api/v1/team/route.ts index 67142982f..760e65a3c 100644 --- a/app/api/v1/team/route.ts +++ b/app/api/v1/team/route.ts @@ -46,7 +46,6 @@ export async function GET(_req: NextRequest): Promise { .from("user_organizations") .select("user_id, role, interface_settings, invited_at, accepted_at, revoked_at, created_at") .eq("organization_id", activeOrg.orgId) - .is("revoked_at", null) .order("created_at", { ascending: true }); if (error) return fail("internal_error", error.message, 500, { requestId }); diff --git a/app/app/ai/agents/[id]/_components/TestPanel.tsx b/app/app/ai/agents/[id]/_components/TestPanel.tsx index 0d5c8d44e..f94f0f625 100644 --- a/app/app/ai/agents/[id]/_components/TestPanel.tsx +++ b/app/app/ai/agents/[id]/_components/TestPanel.tsx @@ -174,6 +174,10 @@ export function TestPanel({ agent, draft, published, readOnly }: Props) { const res = await apiClient.post( `/api/v1/ai/agents/${agent.id}/versions/${target.id}/test`, body, + // O ensaio executa o fluxo real do agente e pode envolver classificadores + // auxiliares antes da resposta final. O timeout padrão de 10 s abortava + // a tela, repetia o POST e criava vários dry-runs concorrentes. + { timeoutMs: 180_000, retry: false }, ); setResult(res.data); qc.invalidateQueries({ queryKey: agentRunsKey(agent.id) }); diff --git a/app/app/ai/agents/_components/AgentRowMenu.tsx b/app/app/ai/agents/_components/AgentRowMenu.tsx index 514dcf29f..ec2e1356d 100644 --- a/app/app/ai/agents/_components/AgentRowMenu.tsx +++ b/app/app/ai/agents/_components/AgentRowMenu.tsx @@ -48,6 +48,7 @@ export function AgentRowMenu({ agent }: Props) { const status = deriveAgentStatus(agent); const isPaused = status === "paused" || status === "draft"; const isArchived = status === "archived"; + const isDefault = agent.is_default; const run = (label: string, action: () => Promise<{ ok: boolean; error?: string; message?: string }>) => { startTransition(async () => { @@ -57,7 +58,11 @@ export function AgentRowMenu({ agent }: Props) { toast.success(label); router.refresh(); } else { - toast.error(res.message ?? `${t("Falha")}: ${res.error ?? "unknown"}`); + const message = + res.error === "cannot_archive_default" + ? t("O agent padrão da organização não pode ser arquivado.") + : res.message ?? `${t("Falha")}: ${res.error ?? "unknown"}`; + toast.error(message); } } catch { toast.error(t("Erro ao executar ação.")); @@ -117,7 +122,12 @@ export function AgentRowMenu({ agent }: Props) { )} { e.preventDefault(); setArchiveOpen(true); diff --git a/app/app/ai/credentials/_components/CredentialCard.tsx b/app/app/ai/credentials/_components/CredentialCard.tsx index 5297f0109..ad0db4188 100644 --- a/app/app/ai/credentials/_components/CredentialCard.tsx +++ b/app/app/ai/credentials/_components/CredentialCard.tsx @@ -42,6 +42,7 @@ interface Props { credential: CredentialRow; canWrite: boolean; usageCount: number; + referenceCount: number; } const STATUS_LABEL: Record = { @@ -60,7 +61,7 @@ const STATUS_VARIANT: Record 0; + const hasReferences = referenceCount > 0; const erro = descreverErroDeValidacao(credential.validation_error); const provedor = PROVEDORES.find((p) => p.id === credential.provider); @@ -105,7 +106,7 @@ export function CredentialCard({ credential, canWrite, usageCount }: Props) { variant="ghost" size="icon" aria-label={t("Excluir credencial")} - disabled={!canWrite || inUse || isPending} + disabled={!canWrite || hasReferences || isPending} onClick={() => setDeleteOpen(true)} > @@ -179,16 +180,17 @@ export function CredentialCard({ credential, canWrite, usageCount }: Props) { > - {inUse ? ( + {hasReferences ? ( {deleteButton} - {t("Em uso por")} {usageCount} {t("agente")} - {usageCount === 1 ? "" : "s"} {t("publicado")} - {usageCount === 1 ? "" : "s"}. + {usageCount > 0 + ? `${t("Em uso por")} ${usageCount} ${t("agente")}${usageCount === 1 ? "" : "s"} ${t("publicado")}${usageCount === 1 ? "" : "s"}. ` + : ""} + {t("Esta credencial ainda é referenciada por versões do agente. Remova essas versões antes de excluí-la.")} diff --git a/app/app/ai/credentials/_components/CredentialsList.tsx b/app/app/ai/credentials/_components/CredentialsList.tsx index d5aeaeff8..b26002b52 100644 --- a/app/app/ai/credentials/_components/CredentialsList.tsx +++ b/app/app/ai/credentials/_components/CredentialsList.tsx @@ -14,6 +14,7 @@ interface Props { initialData: CredentialRow[]; canWrite: boolean; usageMap: Record; + referenceMap: Record; } // Rótulo e ordem saem da lista única — provedor novo aparece na tela sem que @@ -24,7 +25,7 @@ const PROVIDER_LABELS: Record = Object.fromEntries( const PROVIDER_ORDER: Provider[] = PROVEDORES.map((p) => p.id); -export function CredentialsList({ initialData, canWrite, usageMap }: Props) { +export function CredentialsList({ initialData, canWrite, usageMap, referenceMap }: Props) { const t = useT(); const { data } = useCredentialsList({ initialData }); const [addOpen, setAddOpen] = useState(false); @@ -89,6 +90,7 @@ export function CredentialsList({ initialData, canWrite, usageMap }: Props) { credential={row} canWrite={canWrite} usageCount={usageMap[row.id] ?? 0} + referenceCount={referenceMap[row.id] ?? 0} /> ))} diff --git a/app/app/ai/credentials/page.tsx b/app/app/ai/credentials/page.tsx index d02525d5c..97a6d96d9 100644 --- a/app/app/ai/credentials/page.tsx +++ b/app/app/ai/credentials/page.tsx @@ -34,6 +34,7 @@ export default async function CredentialsPage() { // Mesma regra do DELETE: só conta a versão PUBLICADA de agente não arquivado. let usageMap: Record = {}; + const referenceMap: Record = {}; if (credentials.length > 0) { const { data: linked } = await supabase .from("ai_agent_versions") @@ -43,6 +44,10 @@ export default async function CredentialsPage() { .eq("organization_id", activeOrg.orgId) .in("credential_id", credentials.map((c) => c.id)); usageMap = contarUsoPublicado((linked ?? []) as unknown as VersaoVinculada[]); + for (const version of linked ?? []) { + if (!version.credential_id) continue; + referenceMap[version.credential_id] = (referenceMap[version.credential_id] ?? 0) + 1; + } } return ( @@ -60,6 +65,7 @@ export default async function CredentialsPage() { initialData={credentials} canWrite={canWrite} usageMap={usageMap} + referenceMap={referenceMap} /> ); diff --git a/app/app/settings/notifications/page.tsx b/app/app/settings/notifications/page.tsx index c6fd08ca2..f40ae107e 100644 --- a/app/app/settings/notifications/page.tsx +++ b/app/app/settings/notifications/page.tsx @@ -11,7 +11,9 @@ export const dynamic = "force-dynamic"; * * O backend sempre soube: `GET /api/v1/notifications/push` devolve * `enabled: false` sem o par VAPID, e o `PUT` recusa com 503 «Web Push não - * configurado nesta instalação». Quem não perguntava era esta página. + * configurado nesta instalação». O bootstrap local gera esse par + * automaticamente; esta página continua explicando o comando para instalações + * que não usam o instalador. * * O efeito, num primeiro deploy — que é o estado em que NENHUMA instalação tem * as chaves —, era o pior tipo de silêncio: @@ -78,9 +80,12 @@ export default async function NotificationsPage() { marca escrita à mão. «o site» diz a mesma coisa e serve a quem revende o sistema com a marca dele. */} {t( - "Ligar o Push abaixo já faz o aviso aparecer na bandeja do sistema enquanto você está com o site aberto numa aba. Para receber também com a aba fechada, quem administra o servidor precisa gerar um par de chaves uma única vez e reiniciar:", + "Ligar o Push abaixo já faz o aviso aparecer na bandeja do sistema enquanto você está com o site aberto numa aba. Para receber também com a aba fechada, execute o configurador da instalação uma única vez e reinicie os serviços:", )}

+
+            bash scripts/setup-web-push.sh --restart
+          
             npx web-push generate-vapid-keys
           
diff --git a/app/app/team/_components/TeamMembersClient.tsx b/app/app/team/_components/TeamMembersClient.tsx index fbf5a55c5..904158d24 100644 --- a/app/app/team/_components/TeamMembersClient.tsx +++ b/app/app/team/_components/TeamMembersClient.tsx @@ -9,6 +9,7 @@ import { useT } from "@/hooks/i18n/useT"; import { useTeamMembers, type TeamMember } from "@/hooks/team/useTeamMembers"; import { useChangeRole } from "@/hooks/team/useChangeRole"; import { useRevokeMember } from "@/hooks/team/useRevokeMember"; +import { useActivateMember } from "@/hooks/team/useActivateMember"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -54,6 +55,7 @@ export function TeamMembersClient({ currentUserId, canManage }: Props) { const { data, isLoading, isError } = useTeamMembers(); const changeRole = useChangeRole(); const revoke = useRevokeMember(); + const activate = useActivateMember(); const [interfaceMember, setInterfaceMember] = useState(null); const [revokeDialog, setRevokeDialog] = useState(null); @@ -66,7 +68,7 @@ export function TeamMembersClient({ currentUserId, canManage }: Props) { } const members = data?.data ?? []; if (members.length === 0) { - return

{t("Nenhum membro ativo.")}

; + return

{t("Nenhum membro cadastrado.")}

; } return ( @@ -143,7 +145,9 @@ export function TeamMembersClient({ currentUserId, canManage }: Props) { )} - {m.accepted_at ? ( + {m.revoked_at ? ( + {t("Desativado")} + ) : m.accepted_at ? ( {t("Aceito")} ) : ( {t("Pendente")} @@ -164,12 +168,14 @@ export function TeamMembersClient({ currentUserId, canManage }: Props) { - setRevokeDialog(m)} - > - {t("Revogar acesso")} - + {m.revoked_at ? ( + { + try { await activate.mutateAsync(m.user_id); toast.success(t("Membro ativado.")); } + catch { /* showApiError já exibiu a falha */ } + }}>{t("Ativar membro")} + ) : ( + setRevokeDialog(m)}>{t("Desativar membro")} + )} ) : ( diff --git a/app/team/accept-invite/[token]/AutoAcceptInvite.tsx b/app/team/accept-invite/[token]/AutoAcceptInvite.tsx new file mode 100644 index 000000000..376f8e207 --- /dev/null +++ b/app/team/accept-invite/[token]/AutoAcceptInvite.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useState, useTransition } from "react"; + +import { acceptInviteAction } from "@/app/actions/team/acceptInvite"; +import { AcceptInviteForm } from "./AcceptInviteForm"; + +export function AutoAcceptInvite({ + token, + failureLabel, + pendingLabel, + retryLabel, +}: { + token: string; + failureLabel: string; + pendingLabel: string; + retryLabel: string; +}) { + const [failure, setFailure] = useState(false); + const [, startTransition] = useTransition(); + + useEffect(() => { + startTransition(async () => { + const result = await acceptInviteAction(token); + if (!result.ok) setFailure(true); + }); + }, [token]); + + if (failure) { + return ( + <> +

+ {failureLabel} +

+ + + ); + } + + return ( +

+ {pendingLabel} +

+ ); +} diff --git a/app/team/accept-invite/[token]/page.tsx b/app/team/accept-invite/[token]/page.tsx index 8d0fcbffd..292226d05 100644 --- a/app/team/accept-invite/[token]/page.tsx +++ b/app/team/accept-invite/[token]/page.tsx @@ -14,6 +14,7 @@ import { verifyInviteToken } from "@/lib/auth/invite-token"; import { authRateLimited, AUTH_LIMITS } from "@/lib/auth/rate-limit"; import { createClient } from "@/lib/supabase/server"; import { AcceptInviteForm } from "./AcceptInviteForm"; +import { AutoAcceptInvite } from "./AutoAcceptInvite"; import { normalizarIdioma } from "@/lib/i18n/idiomas"; import { traduzir } from "@/lib/i18n/dicionario"; @@ -21,10 +22,12 @@ export const dynamic = "force-dynamic"; interface PageProps { params: Promise<{ token: string }>; + searchParams?: Promise<{ auto?: string }>; } -export default async function AcceptInvitePage({ params }: PageProps) { +export default async function AcceptInvitePage({ params, searchParams }: PageProps) { const { token } = await params; + const autoAceitar = (await searchParams)?.auto === "1"; // Rota pública, fora da árvore de `app/app/layout.tsx` — sem `IdiomaProvider`, // então resolve o idioma direto, como `admin/forbidden/page.tsx`. Buscado @@ -130,10 +133,20 @@ export default async function AcceptInvitePage({ params }: PageProps) {

{t("Aceitar convite")}

- {t("Você foi convidado para entrar como")} {payload.role}.{" "} - {t("Confirme abaixo para ativar seu acesso.")} + {autoAceitar + ? t("Seu acesso está sendo ativado automaticamente.") + : <>{t("Você foi convidado para entrar como")} {payload.role}.{" "}{t("Confirme abaixo para ativar seu acesso.")}}

- + {autoAceitar ? ( + + ) : ( + + )}
); } diff --git a/components/app/ConexaoCaidaBanner.tsx b/components/app/ConexaoCaidaBanner.tsx index 186b29aa8..37d27c8c5 100644 --- a/components/app/ConexaoCaidaBanner.tsx +++ b/components/app/ConexaoCaidaBanner.tsx @@ -15,7 +15,7 @@ * * ─── Por que não é e-mail ────────────────────────────────────────────────── * - * Seria melhor: chega mesmo com o navegador fechado. Mas `RESEND_API_KEY` é + * Seria melhor: chega mesmo com o navegador fechado. Mas SMTP é * opcional e está VAZIA numa instalação real — e um aviso que depende de env * opcional é um aviso que não existe justamente em quem instalou sozinho e não * configurou nada. A faixa funciona em toda instalação, sem configurar nada. diff --git a/components/shell/Sidebar.tsx b/components/shell/Sidebar.tsx index 787f8c14d..dd553ec8c 100644 --- a/components/shell/Sidebar.tsx +++ b/components/shell/Sidebar.tsx @@ -122,10 +122,10 @@ export function SidebarContent({ // em vez de next/image de propósito: a URL vem de quem hospeda // (banco ou .env), e next/image exige allowlist de domínios fechada em // build — a imagem pré-buildada rejeitaria o domínio do self-hoster. - // Altura fixa e largura livre porque a arte enviada tem proporção - // desconhecida; forçar as duas distorceria o logo de quem configurou. + // A área de marca usa a medida aprovada pela interface (150×52px), + // mantendo object-contain para não distorcer a arte enviada. // eslint-disable-next-line @next/next/no-img-element - {nome} + {nome} ) : ( {nome} )} diff --git a/docker-compose.local.yml b/docker-compose.local.yml index be03c4591..98413487e 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -74,7 +74,10 @@ services: env_file: ${LOCAL_ENV_FILE:-.env.local} environment: TZ: UTC - INTERNAL_SECRET: ${INTERNAL_SECRET} + # Os crons autenticam com INTERNAL_CRON_SECRET quando definido; o app + # aplica a mesma prioridade. Sem este fallback, o scheduler enviava + # INTERNAL_SECRET e recebia 401 em instalações com os dois segredos. + INTERNAL_SECRET: ${INTERNAL_CRON_SECRET:-${INTERNAL_SECRET}} depends_on: app: condition: service_healthy diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f6f0820c9..d06624ccc 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -220,7 +220,8 @@ services: condition: service_healthy environment: TZ: UTC - INTERNAL_SECRET: ${INTERNAL_SECRET} + # Deve ser o mesmo segredo que as rotas de cron priorizam no app. + INTERNAL_SECRET: ${INTERNAL_CRON_SECRET:-${INTERNAL_SECRET}} logging: *default-logging networks: [internal] diff --git a/hooks/team/useActivateMember.ts b/hooks/team/useActivateMember.ts new file mode 100644 index 000000000..0856a1259 --- /dev/null +++ b/hooks/team/useActivateMember.ts @@ -0,0 +1,14 @@ +"use client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api/client"; +import { showApiError } from "@/components/feedback/ApiErrorToast"; + +export function useActivateMember() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (userId: string) => apiClient.post(`/api/v1/team/${userId}/activate`, {}), + onError: showApiError, + onSuccess: () => qc.invalidateQueries({ queryKey: ["team"] }), + }); +} + diff --git a/lib/agent-engine/agent/inbound-turn.ts b/lib/agent-engine/agent/inbound-turn.ts index ea691d113..3b288d92e 100644 --- a/lib/agent-engine/agent/inbound-turn.ts +++ b/lib/agent-engine/agent/inbound-turn.ts @@ -3353,17 +3353,26 @@ async function executarTurnoDoAgente( let stageSuggestion: LeadStage | null = null; let stageHintBlock = ''; if (deps.knobs.stageClassifier !== undefined) { - stageSuggestion = await classifyStage( - pool, - deps.llmCfg, - { tenantId, leadId: leadId || null, jobId: job?.id }, - { - context: effectiveContext, - currentStage, - ...argsAux(deps.knobs.stageClassifier.model), - }, - { registry: deps.registry, log: runLog }, - ); + try { + stageSuggestion = await classifyStage( + pool, + deps.llmCfg, + { tenantId, leadId: leadId || null, jobId: job?.id }, + { + context: effectiveContext, + currentStage, + ...argsAux(deps.knobs.stageClassifier.model), + }, + { registry: deps.registry, log: runLog }, + ); + } catch (err) { + // É uma sugestão para o conversador, não uma condição para atendê-lo. + // Orçamento é a exceção: precisa subir até a escolta que faz o handoff. + if (err instanceof LlmBudgetExceededError) throw err; + runLog.warn('stage-classifier falhou — turno segue sem sugestão de estágio', { + error: (err instanceof Error ? err.message : String(err)).slice(0, 200), + }); + } if (stageSuggestion !== null) { stageHintBlock = renderStageHint(stageSuggestion, currentStage); } @@ -3375,24 +3384,33 @@ async function executarTurnoDoAgente( // (a mensagem/reason nunca vão a log). A correlação com promessa fora de tabela escala no fim. let jailbreakLevel: JailbreakLevel = 'none'; if (camadaLigada(camadas.jailbreak, deps.knobs.jailbreak !== undefined)) { - const verdict = await classifyJailbreak( - pool, - deps.llmCfg, - { tenantId, leadId: leadId || null, jobId: job?.id }, - { - message: skillSignal, - // Knob ausente + organização ligando = roda com o modelo padrão dela, - // que é a convenção já usada pelo stageClassifier. - ...argsAux(deps.knobs.jailbreak?.model), - }, - { registry: deps.registry, log: runLog }, - ); - jailbreakLevel = verdict.level; - if (verdict.flag) { - // trace do turno: só flag/level (não PII) — a mensagem e o reason nunca são logados. - runLog.warn('jailbreak: sinal detectado na mensagem do lead', { - jailbreak_flag: true, - jailbreak_level: verdict.level, + try { + const verdict = await classifyJailbreak( + pool, + deps.llmCfg, + { tenantId, leadId: leadId || null, jobId: job?.id }, + { + message: skillSignal, + // Knob ausente + organização ligando = roda com o modelo padrão dela, + // que é a convenção já usada pelo stageClassifier. + ...argsAux(deps.knobs.jailbreak?.model), + }, + { registry: deps.registry, log: runLog }, + ); + jailbreakLevel = verdict.level; + if (verdict.flag) { + // trace do turno: só flag/level (não PII) — a mensagem e o reason nunca são logados. + runLog.warn('jailbreak: sinal detectado na mensagem do lead', { + jailbreak_flag: true, + jailbreak_level: verdict.level, + }); + } + } catch (err) { + // A camada é advisória: falha do fornecedor não pode deixar o lead sem + // resposta. Orçamento continua propagando para a escolta do turno. + if (err instanceof LlmBudgetExceededError) throw err; + runLog.warn('jailbreak: classificador falhou — turno segue sem sinal', { + error: (err instanceof Error ? err.message : String(err)).slice(0, 200), }); } } diff --git a/lib/ai/credenciais/guardar.ts b/lib/ai/credenciais/guardar.ts index 0b5cdbfde..ca2f368af 100644 --- a/lib/ai/credenciais/guardar.ts +++ b/lib/ai/credenciais/guardar.ts @@ -115,7 +115,13 @@ async function validarEmSegundoPlano( validation_error: null, models_available: r.models, } - : { validated_at: null, validation_error: r.error }, + : { + validated_at: null, + validation_error: r.error, + // Um catálogo antigo não representa uma chave que acabou de + // falhar; mantê-lo confundiria o diagnóstico da tela. + models_available: null, + }, ) .eq("id", credentialId) .eq("organization_id", organizationId); diff --git a/lib/api/client.test.ts b/lib/api/client.test.ts index 57d8c4feb..400c33f62 100644 --- a/lib/api/client.test.ts +++ b/lib/api/client.test.ts @@ -95,4 +95,16 @@ describe("apiClient", () => { const headers = fetchMock.mock.calls[0]![1].headers as Record; expect(headers["Idempotency-Key"]).toBe("custom-key-123"); }); + + it("t8: retry=false não repete uma operação longa que recebeu 503", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(503, { error: { code: "service_unavailable", message: "aguarde" } }), + ); + + await expect(apiClient.post("/x", {}, { retry: false })).rejects.toMatchObject({ + status: 503, + code: "service_unavailable", + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/lib/api/client.ts b/lib/api/client.ts index 2928c2171..a1b6b04b4 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -9,6 +9,12 @@ export type RequestOpts = { schema?: ZodSchema; idempotencyKey?: string; timeoutMs?: number; + /** + * Alguns POSTs são trabalhos longos e caros (ex.: ensaio de um agent). Mesmo + * com Idempotency-Key, a rota pode ainda estar trabalhando quando o navegador + * desiste; repetir a chamada criaria execuções paralelas desnecessárias. + */ + retry?: boolean; headers?: Record; signal?: AbortSignal; }; @@ -123,7 +129,9 @@ async function request( let lastError: unknown; - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const maxAttempts = opts.retry === false ? 1 : MAX_ATTEMPTS; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { const timeoutController = new AbortController(); const timer = setTimeout(() => timeoutController.abort(), timeoutMs); const signal = combineSignals([timeoutController.signal, opts.signal]); @@ -148,7 +156,7 @@ async function request( } // Retry on 429/503 - if (RETRYABLE_STATUSES.has(res.status) && attempt < MAX_ATTEMPTS) { + if (RETRYABLE_STATUSES.has(res.status) && attempt < maxAttempts) { const retryAfter = parseRetryAfterSeconds(res.headers.get("Retry-After")); const delay = retryAfter !== null ? retryAfter * 1000 : backoffMs(attempt); await sleep(delay, opts.signal); @@ -187,7 +195,7 @@ async function request( } // Network error / timeout — retry lastError = err; - if (attempt < MAX_ATTEMPTS) { + if (attempt < maxAttempts) { await sleep(backoffMs(attempt), opts.signal); continue; } diff --git a/scripts/local-env.sh b/scripts/local-env.sh index 303d6679c..c229e9011 100755 --- a/scripts/local-env.sh +++ b/scripts/local-env.sh @@ -20,6 +20,21 @@ env_value() { awk -F= -v key="$key" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "$ENV_FILE" } +ensure_vapid_keys() { + local public_key private_key keys_json + public_key="$(env_value VAPID_PUBLIC_KEY)" + private_key="$(env_value VAPID_PRIVATE_KEY)" + if [[ -n "$public_key" && -n "$private_key" ]]; then + return + fi + keys_json="$(node -e 'const webpush = require("web-push"); process.stdout.write(JSON.stringify(webpush.generateVAPIDKeys()))')" + public_key="$(printf '%s' "$keys_json" | node -e 'let s=""; process.stdin.on("data", c => s += c).on("end", () => process.stdout.write(JSON.parse(s).publicKey))')" + private_key="$(printf '%s' "$keys_json" | node -e 'let s=""; process.stdin.on("data", c => s += c).on("end", () => process.stdout.write(JSON.parse(s).privateKey))')" + printf '\n# Geradas automaticamente por local-env.sh; mantenha estas chaves para preservar as inscrições.\nVAPID_PUBLIC_KEY=%s\nVAPID_PRIVATE_KEY=%s\n' "$public_key" "$private_key" >> "$ENV_FILE" + chmod 600 "$ENV_FILE" + printf 'Chaves VAPID geradas automaticamente em %s\n' "$ENV_FILE" +} + has_local_env() { [[ "$(env_value DESKCOMM_ENV_MODE)" == "local" ]] || return 1 local key @@ -94,6 +109,7 @@ INTERNAL_CRON_SECRET=$(openssl rand -hex 32) CPF_ENCRYPTION_KEY=$(openssl rand -base64 32) WAHA_BYO_ENCRYPTION_KEY=$(openssl rand -base64 32) AI_CRED_AES_KEY=$(openssl rand -base64 32) +NUVEMSHOP_OAUTH_ENCRYPTION_KEY=$(openssl rand -hex 32) WAHA_API_BASE_URL=http://waha:3000 WAHA_API_KEY=deskcomm-local-key @@ -118,6 +134,7 @@ EOF case "${1:-ensure}" in ensure) if ! has_local_env; then generate; fi + ensure_vapid_keys ;; generate|force) generate diff --git a/scripts/local-stack.sh b/scripts/local-stack.sh index b854d8e9f..4b65a8e02 100755 --- a/scripts/local-stack.sh +++ b/scripts/local-stack.sh @@ -24,11 +24,26 @@ ensure_supabase() { fi } +ensure_encryption_key() { + local key db_url + key="$(awk -F= '$1 == "NUVEMSHOP_OAUTH_ENCRYPTION_KEY" { sub(/^[^=]*=/, ""); print; exit }' "$ENV_FILE")" + if [[ -z "$key" ]]; then + key="$(openssl rand -hex 32)" + printf '\nNUVEMSHOP_OAUTH_ENCRYPTION_KEY=%s\n' "$key" >> "$ENV_FILE" + fi + db_url="$(./scripts/local-supabase.sh status | node -e 'let s=""; process.stdin.on("data", c => s += c).on("end", () => process.stdout.write(JSON.parse(s).DB_URL || ""))')" + [[ -n "$db_url" ]] || { printf 'Erro: Supabase local não retornou DB_URL.\n' >&2; exit 1; } + docker run --rm --network host postgres:15-alpine psql "$db_url" -v ON_ERROR_STOP=1 -c \ + "insert into private.app_secrets (name, value) values ('nuvemshop_oauth_key', '$key') on conflict (name) do update set value = excluded.value, updated_at = now();" \ + >/dev/null +} + case "${1:-}" in up) ensure_supabase ./scripts/local-env.sh ensure require_env + ensure_encryption_key "${COMPOSE[@]}" up -d --build ;; down) diff --git a/scripts/setup-web-push.sh b/scripts/setup-web-push.sh new file mode 100755 index 000000000..4c886e4b5 --- /dev/null +++ b/scripts/setup-web-push.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +ENV_FILE="${LOCAL_ENV_FILE:-.env.local}" +if [[ ! -f "$ENV_FILE" ]]; then + printf 'Erro: %s não existe. Execute o instalador da VM primeiro.\n' "$ENV_FILE" >&2 + exit 1 +fi + +./scripts/local-env.sh ensure + +if [[ "${1:-}" == "--restart" ]]; then + ./scripts/local-stack.sh up +else + printf 'Web Push configurado em %s.\n' "$ENV_FILE" + printf 'Para aplicar no Docker local/VM: bash scripts/local-stack.sh up\n' +fi diff --git a/supabase/migrations/20260911123411_configuracao_resend_e_ativacao_membros.sql b/supabase/migrations/20260911123411_configuracao_resend_e_ativacao_membros.sql new file mode 100644 index 000000000..c29a00409 --- /dev/null +++ b/supabase/migrations/20260911123411_configuracao_resend_e_ativacao_membros.sql @@ -0,0 +1,26 @@ +-- Configuração do remetente Resend por instalação. +-- O segredo só é acessível pelo service_role e fica cifrado pela mesma RPC +-- usada nas demais credenciais da instalação. +create table if not exists public.platform_resend_settings ( + id smallint primary key default 1, + api_key_encrypted bytea, + from_email text, + updated_at timestamptz not null default now(), + updated_by uuid, + constraint platform_resend_settings_singleton check (id = 1), + constraint platform_resend_settings_from_email check ( + from_email is null or from_email ~* '^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$' + ) +); + +comment on table public.platform_resend_settings is + 'Remetente Resend desta instalação. Server-side only; a API key nunca volta ao browser e nunca é gravada em claro.'; + +alter table public.platform_resend_settings enable row level security; +revoke all on public.platform_resend_settings from anon, authenticated; +grant select, insert, update on public.platform_resend_settings to service_role; + +drop trigger if exists trg_platform_resend_settings_updated_at on public.platform_resend_settings; +create trigger trg_platform_resend_settings_updated_at + before update on public.platform_resend_settings + for each row execute function public.fn_set_updated_at(); diff --git a/tests/e2e/marca-logo.spec.ts b/tests/e2e/marca-logo.spec.ts index 6b7cff125..b79db84d3 100644 --- a/tests/e2e/marca-logo.spec.ts +++ b/tests/e2e/marca-logo.spec.ts @@ -16,10 +16,9 @@ * 1. **A camada da instalação pinta a fachada.** O logo do dono do servidor * aparece na barra lateral E no `/login` de quem não entrou — a P0 de * primeira impressão. - * 2. **A camada da organização NÃO vaza para a fachada.** O logo do cliente - * final troca a barra lateral dele e o `/login` continua sendo o do - * revendedor. É a propriedade que separa "marca própria" de "qualquer um - * repinta a instalação". + * 2. **O logo da organização também chega à fachada.** Ao subir o logo em + * Configurações → Marca, a aplicação sincroniza o arquivo com a instalação + * para que o `/login` use a mesma identidade visual antes da autenticação. * 3. **O que não é imagem não entra.** Um SVG renomeado para `.png` é recusado * pelos BYTES, com a razão dita em português, e nada muda na tela. * @@ -548,7 +547,7 @@ test.describe("o logo subido pela tela chega à tela", () => { await contexto.close(); }); - test("(3) o logo da EMPRESA troca a barra dela e NÃO vaza para a tela de acesso", async ({ + test("(3) o logo da EMPRESA troca a barra dela e chega à tela de acesso", async ({ page, browser, }) => { @@ -572,13 +571,12 @@ test.describe("o logo subido pela tela chega à tela", () => { baixou(barra!, "barra lateral da empresa"); await page.screenshot({ path: evidencia("4-sidebar-da-empresa.png") }); - // A camada de cima NÃO alcança a fachada: quem não entrou continua vendo o - // logo do revendedor. Sem esta asserção, o caso (1) e o (3) seriam - // indistinguíveis de "o último upload repinta tudo". + // O login não tem organização ativa. O upload da empresa sincroniza o + // ponteiro da instalação para que a mesma imagem apareça antes do login. const noLogin = await logoDoLogin(browser); expect(noLogin, "a tela de acesso ficou sem logo depois do upload da empresa").not.toBeNull(); - expect(noLogin!.src).toContain(`${PREFIXO_PUBLICO}platform/`); - expect(noLogin!.src).not.toContain(`${PREFIXO_PUBLICO}${creds.org_id}/`); + expect(noLogin!.src).toContain(`${PREFIXO_PUBLICO}${creds.org_id}/`); + baixou(noLogin!, "tela de acesso com logo da empresa"); }); test("(4) SVG renomeado para .png é recusado pelos BYTES, com a razão dita", async ({ page }) => { diff --git a/tests/unit/email-marca-e-remetente.test.ts b/tests/unit/email-marca-e-remetente.test.ts index e06ea9720..35034b4ce 100644 --- a/tests/unit/email-marca-e-remetente.test.ts +++ b/tests/unit/email-marca-e-remetente.test.ts @@ -7,7 +7,7 @@ * e-mails de LGPD de todo clone diziam ter sido processados pelo DeskcommCRM, e * ninguém foi avisado por gate nenhum. */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { NEUTROS_DE_SAIDA, type MarcaDeSaida } from "@/lib/branding/saida"; import { buildInviteEmail } from "@/lib/email/templates/invite"; @@ -123,112 +123,41 @@ describe("convite de time", () => { }); describe("remetente", () => { - const ORIGINAIS = { - key: process.env.RESEND_API_KEY, - from: process.env.RESEND_FROM_EMAIL, + const config = { + host: "smtp.revenda.com.br", + port: 465, + security: "tls" as const, + username: "nao-responda@revenda.com.br", + password: "segredo", + fromEmail: "nao-responda@revenda.com.br", + fromName: "", + source: "environment" as const, }; - beforeEach(() => { - vi.resetModules(); - }); + it("sem remetente não existe e-mail de saída — e NUNCA um domínio do produto", async () => { + const { formatFromAddress, isSmtpConfigured } = await import("@/lib/email/smtp"); - afterEach(() => { - for (const [chave, valor] of [ - ["RESEND_API_KEY", ORIGINAIS.key], - ["RESEND_FROM_EMAIL", ORIGINAIS.from], - ] as const) { - if (valor === undefined) delete process.env[chave]; - else process.env[chave] = valor; - } - vi.resetModules(); - }); - - it("sem RESEND_FROM_EMAIL não existe remetente — e NUNCA um domínio do produto", async () => { - process.env.RESEND_FROM_EMAIL = ""; - const { fromAddress } = await import("@/lib/email/resend"); - - expect(fromAddress("Vendas Turbo")).toBeNull(); + expect(isSmtpConfigured({ ...config, fromEmail: "" })).toBe(false); + expect(formatFromAddress({ ...config, fromEmail: "" }, "Vendas Turbo")).toBeNull(); }); it("o endereço é do operador e o NOME é da marca", async () => { - process.env.RESEND_FROM_EMAIL = "nao-responda@revenda.com.br"; - const { fromAddress } = await import("@/lib/email/resend"); + const { formatFromAddress } = await import("@/lib/email/smtp"); - expect(fromAddress("Vendas Turbo")).toBe("Vendas Turbo "); + expect(formatFromAddress(config, "Vendas Turbo")).toBe( + "Vendas Turbo ", + ); // Sem marca não se inventa uma: sai o endereço puro. - expect(fromAddress()).toBe("nao-responda@revenda.com.br"); + expect(formatFromAddress(config)).toBe("nao-responda@revenda.com.br"); }); it("nome de marca não injeta cabeçalho SMTP", async () => { - process.env.RESEND_FROM_EMAIL = "nao-responda@revenda.com.br"; - const { fromAddress } = await import("@/lib/email/resend"); - - const sujo = fromAddress('Acme" \r\nBcc: vitima@y.com'); + const { formatFromAddress } = await import("@/lib/email/smtp"); + const sujo = formatFromAddress(config, 'Acme" \r\nBcc: vitima@y.com'); expect(sujo).not.toContain("\r"); expect(sujo).not.toContain("\n"); // `<`, `>`, `"` e as quebras somem; o resto do texto fica, colado — o que // importa é que não sobrou cabeçalho nenhum para o SMTP interpretar. expect(sujo).toBe("Acme evil@x.comBcc: vitima@y.com "); }); - - it("chave configurada mas remetente vazio = NÃO CONFIGURADO, não envio quebrado", async () => { - // É a decisão que joga o fluxo no caminho bom que já existe: `pending_review` - // no worker de LGPD e o link de aceite na tela do convite. Antes, o domínio - // herdado fazia a Resend recusar e o operador caçava rede e contêiner. - process.env.RESEND_API_KEY = "re_chave_valida_de_teste"; - process.env.RESEND_FROM_EMAIL = ""; - const { sendEmail, isEmailConfigured } = await import("@/lib/email/resend"); - - expect(isEmailConfigured()).toBe(false); - const r = await sendEmail({ to: "a@b.com", subject: "s", html: "

x

" }); - expect(r).toEqual({ ok: false, error: "not_configured" }); - }); - - it("domínio não verificado tem NOME próprio, e não vira 'send_failed' genérico", async () => { - process.env.RESEND_API_KEY = "re_chave_valida_de_teste"; - process.env.RESEND_FROM_EMAIL = "nao-responda@revenda.com.br"; - vi.doMock("resend", () => ({ - Resend: class { - emails = { - send: async () => ({ - data: null, - error: { - name: "validation_error", - message: - "The revenda.com.br domain is not verified. Please add and verify your domain on https://resend.com/domains", - }, - }), - }; - }, - })); - const { sendEmail } = await import("@/lib/email/resend"); - - const r = await sendEmail({ to: "a@b.com", subject: "s", html: "

x

" }); - expect(r.error).toBe("dominio_nao_verificado"); - // A mensagem crua continua disponível: falhar fechado na AÇÃO, aberto na - // INFORMAÇÃO. - expect(r.details).toContain("not verified"); - vi.doUnmock("resend"); - }); - - it("limite de envio continua distinguível do resto", async () => { - process.env.RESEND_API_KEY = "re_chave_valida_de_teste"; - process.env.RESEND_FROM_EMAIL = "nao-responda@revenda.com.br"; - vi.doMock("resend", () => ({ - Resend: class { - emails = { - send: async () => ({ - data: null, - error: { name: "rate_limit_exceeded", message: "Too many requests" }, - }), - }; - }, - })); - const { sendEmail } = await import("@/lib/email/resend"); - - expect((await sendEmail({ to: "a@b.com", subject: "s", html: "x" })).error).toBe( - "rate_limited", - ); - vi.doUnmock("resend"); - }); }); diff --git a/unistall_docker.sh b/unistall_docker.sh new file mode 100755 index 000000000..f6bcffab1 --- /dev/null +++ b/unistall_docker.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# +# Remove todos os recursos do daemon Docker selecionado: containers, imagens, +# volumes, redes personalizadas e cache de build. O Docker em si permanece +# instalado; este script nao remove configuracoes do daemon nem credenciais. + +set -Eeuo pipefail + +readonly CONFIRMACAO="ZERAR-DOCKER" + +uso() { + cat <<'EOF' +Uso: bash unistall_docker.sh [--force] + +Para todos os recursos do daemon Docker atualmente selecionado: + 1. para e remove todos os containers; + 2. remove todas as imagens e todos os volumes (inclusive bancos de dados); + 3. remove redes Docker personalizadas e o cache de build. + +O Docker continua instalado. Redes internas padrao (bridge, host e none), +configuracoes do daemon e credenciais locais nao sao removidas. + +Sem --force, digite ZERAR-DOCKER para confirmar a operacao irreversivel. +EOF +} + +falhar() { + printf 'Erro: %s\n' "$*" >&2 + exit 1 +} + +forcar=false +case "${1:-}" in + "") ;; + --force) forcar=true ;; + -h|--help) + uso + exit 0 + ;; + *) + uso >&2 + exit 2 + ;; +esac + +command -v docker >/dev/null 2>&1 || falhar "Docker nao esta instalado ou nao esta no PATH." +docker info >/dev/null 2>&1 || falhar "Nao foi possivel acessar o daemon Docker atual." + +contexto="$(docker context show 2>/dev/null || true)" +printf 'Daemon Docker selecionado: %s\n' "${contexto:-desconhecido}" +printf '\nATENCAO: esta operacao apaga todos os dados Docker desse daemon, incluindo volumes de banco de dados.\n' +printf 'Ela nao pode ser desfeita.\n\n' + +if [[ "$forcar" != true ]]; then + read -r -p "Digite ${CONFIRMACAO} para continuar: " resposta + [[ "$resposta" == "$CONFIRMACAO" ]] || falhar "Operacao cancelada." +fi + +mapfile -t containers_em_execucao < <(docker container ls -q) +if ((${#containers_em_execucao[@]})); then + printf 'Parando %d container(s)...\n' "${#containers_em_execucao[@]}" + docker container stop "${containers_em_execucao[@]}" +else + printf 'Containers em execucao: nenhum.\n' +fi + +mapfile -t todos_containers < <(docker container ls -aq) +if ((${#todos_containers[@]})); then + printf 'Removendo %d container(s)...\n' "${#todos_containers[@]}" + docker container rm -f "${todos_containers[@]}" +else + printf 'Containers: nada para remover.\n' +fi + +mapfile -t todos_volumes < <(docker volume ls -q) +if ((${#todos_volumes[@]})); then + printf 'Removendo %d volume(s)...\n' "${#todos_volumes[@]}" + docker volume rm -f "${todos_volumes[@]}" +else + printf 'Volumes: nada para remover.\n' +fi + +mapfile -t todas_imagens < <(docker image ls -aq) +if ((${#todas_imagens[@]})); then + printf 'Removendo %d imagem(ns)...\n' "${#todas_imagens[@]}" + docker image rm -f "${todas_imagens[@]}" +else + printf 'Imagens: nada para remover.\n' +fi + +printf 'Removendo redes Docker personalizadas nao utilizadas...\n' +docker network prune --force + +printf 'Removendo cache de build...\n' +docker builder prune --all --force + +printf 'Executando limpeza final de recursos sem uso...\n' +docker system prune --all --volumes --force + +printf '\nLimpeza concluida. O daemon Docker permanece instalado.\n'