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 */}
)}
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}
)}
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'