Fix branding sync and AI credential safeguards

This commit is contained in:
betoarts
2026-09-17 09:37:59 -03:00
parent 86fc3a140a
commit ae39797910
32 changed files with 581 additions and 181 deletions
+9
View File
@@ -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.
+22
View File
@@ -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)
+7 -6
View File
@@ -52,24 +52,25 @@ export default async function PublicLayout({ children }: { children: React.React
<img> 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 <img> 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 <img> 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 */}
<img
data-testid="logo-da-fachada"
src={marca.logoUrl}
alt={marca.nome}
className="h-10 w-auto max-w-[12rem] object-contain"
className="h-[62px] w-[226px] object-contain"
/>
</div>
)}
@@ -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
+70 -8
View File
@@ -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<string | null> {
* 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<Recusa | null> {
async function gravarCaminho(
ctx: Contexto,
caminho: string | null,
caminhoDaFachada: string | null = caminho,
): Promise<Recusa | null> {
const admin = createAdminClient();
if (ctx.escopo === "instalacao") {
const { error } = await admin
@@ -278,6 +283,24 @@ async function gravarCaminho(ctx: Contexto, caminho: string | null): Promise<Rec
if (data !== 1) {
return { codigo: "internal_error", mensagem: "O logo não foi gravado.", status: 500 };
}
// O login acontece antes de existir uma organização ativa. Por isso, quando
// o admin define o logo nas Configurações da empresa, uma cópia no prefixo da
// instalação passa a ser usada pela fachada pública. O banco não permite que
// `platform_branding.logo_path` aponte para o prefixo de um tenant.
const { error: erroFachada } = await admin
.from("platform_branding")
.upsert(
{ id: 1, logo_path: caminhoDaFachada, seeded_from_env: false },
{ onConflict: "id" },
);
if (erroFachada) {
logger.error("[marca/logo] sincronização da fachada falhou", {
codigo: erroFachada.code,
detalhe: erroFachada.message,
});
return { codigo: "internal_error", mensagem: "Erro ao sincronizar o logo do login.", status: 500 };
}
invalidarMarcaDaInstalacao();
return null;
}
@@ -289,12 +312,16 @@ async function gravarCaminho(ctx: Contexto, caminho: string | null): Promise<Rec
* silenciosa — um caminho fora do escopo é sinal de dado adulterado, não de
* limpeza rotineira, e é o único lugar onde esse sinal aparece.
*/
async function apagarAnterior(ctx: Contexto, anterior: string | null): Promise<void> {
async function apagarAnterior(
ctx: Contexto,
anterior: string | null,
prefixo = ctx.prefixo,
): Promise<void> {
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<Response> {
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<Response> {
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<Response> {
}
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 });
@@ -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<Response> {
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 });
}
-1
View File
@@ -46,7 +46,6 @@ export async function GET(_req: NextRequest): Promise<Response> {
.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 });
@@ -174,6 +174,10 @@ export function TestPanel({ agent, draft, published, readOnly }: Props) {
const res = await apiClient.post<TestResponse>(
`/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) });
+12 -2
View File
@@ -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) {
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isArchived}
disabled={isArchived || isDefault}
title={
isDefault
? t("O agent padrão da organização não pode ser arquivado.")
: undefined
}
onSelect={(e) => {
e.preventDefault();
setArchiveOpen(true);
@@ -42,6 +42,7 @@ interface Props {
credential: CredentialRow;
canWrite: boolean;
usageCount: number;
referenceCount: number;
}
const STATUS_LABEL: Record<CredentialStatus, string> = {
@@ -60,7 +61,7 @@ const STATUS_VARIANT: Record<CredentialStatus, "default" | "secondary" | "destru
inactive: "outline",
};
export function CredentialCard({ credential, canWrite, usageCount }: Props) {
export function CredentialCard({ credential, canWrite, usageCount, referenceCount }: Props) {
const t = useT();
const router = useRouter();
const qc = useQueryClient();
@@ -69,7 +70,7 @@ export function CredentialCard({ credential, canWrite, usageCount }: Props) {
const status = credentialStatus(credential);
const last4 = credential.api_key_last4 ?? "????";
const inUse = usageCount > 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)}
>
<Trash size={14} aria-hidden />
@@ -179,16 +180,17 @@ export function CredentialCard({ credential, canWrite, usageCount }: Props) {
>
<ArrowsClockwise size={14} aria-hidden />
</Button>
{inUse ? (
{hasReferences ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>{deleteButton}</span>
</TooltipTrigger>
<TooltipContent>
{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.")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -14,6 +14,7 @@ interface Props {
initialData: CredentialRow[];
canWrite: boolean;
usageMap: Record<string, number>;
referenceMap: Record<string, number>;
}
// Rótulo e ordem saem da lista única — provedor novo aparece na tela sem que
@@ -24,7 +25,7 @@ const PROVIDER_LABELS: Record<string, string> = 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}
/>
</li>
))}
+6
View File
@@ -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<string, number> = {};
const referenceMap: Record<string, number> = {};
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}
/>
</div>
);
+7 -2
View File
@@ -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:",
)}
</p>
<pre className="mt-2 overflow-x-auto rounded-md bg-muted p-2 text-xs">
<code>bash scripts/setup-web-push.sh --restart</code>
</pre>
<pre className="mt-2 overflow-x-auto rounded-md bg-muted p-2 text-xs">
<code>npx web-push generate-vapid-keys</code>
</pre>
+14 -8
View File
@@ -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<TeamMember | null>(null);
const [revokeDialog, setRevokeDialog] = useState<TeamMember | null>(null);
@@ -66,7 +68,7 @@ export function TeamMembersClient({ currentUserId, canManage }: Props) {
}
const members = data?.data ?? [];
if (members.length === 0) {
return <p className="text-sm text-muted-foreground">{t("Nenhum membro ativo.")}</p>;
return <p className="text-sm text-muted-foreground">{t("Nenhum membro cadastrado.")}</p>;
}
return (
@@ -143,7 +145,9 @@ export function TeamMembersClient({ currentUserId, canManage }: Props) {
)}
</TableCell>
<TableCell>
{m.accepted_at ? (
{m.revoked_at ? (
<Badge variant="destructive">{t("Desativado")}</Badge>
) : m.accepted_at ? (
<Badge variant="default">{t("Aceito")}</Badge>
) : (
<Badge variant="outline">{t("Pendente")}</Badge>
@@ -164,12 +168,14 @@ export function TeamMembersClient({ currentUserId, canManage }: Props) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setRevokeDialog(m)}
>
{t("Revogar acesso")}
</DropdownMenuItem>
{m.revoked_at ? (
<DropdownMenuItem onClick={async () => {
try { await activate.mutateAsync(m.user_id); toast.success(t("Membro ativado.")); }
catch { /* showApiError já exibiu a falha */ }
}}>{t("Ativar membro")}</DropdownMenuItem>
) : (
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => setRevokeDialog(m)}>{t("Desativar membro")}</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
@@ -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 (
<>
<p role="alert" className="mt-4 text-sm text-destructive">
{failureLabel}
</p>
<AcceptInviteForm
token={token}
label={retryLabel}
pendingLabel={pendingLabel}
failureLabel={failureLabel}
/>
</>
);
}
return (
<p role="status" className="mt-4 text-sm text-muted-foreground">
{pendingLabel}
</p>
);
}
+17 -4
View File
@@ -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) {
<Shell>
<h1 className="text-xl font-semibold">{t("Aceitar convite")}</h1>
<p className="mt-2 text-sm text-muted-foreground">
{t("Você foi convidado para entrar como")} <strong>{payload.role}</strong>.{" "}
{t("Confirme abaixo para ativar seu acesso.")}
{autoAceitar
? t("Seu acesso está sendo ativado automaticamente.")
: <>{t("Você foi convidado para entrar como")} <strong>{payload.role}</strong>.{" "}{t("Confirme abaixo para ativar seu acesso.")}</>}
</p>
<AcceptInviteForm token={token} label={t("Aceitar convite")} pendingLabel={t("Confirmando…")} failureLabel={t("Não foi possível aceitar este convite. Ele pode ter vencido ou seu acesso foi revogado. Peça um novo link ao administrador.")} />
{autoAceitar ? (
<AutoAcceptInvite
token={token}
pendingLabel={t("Ativando acesso…")}
retryLabel={t("Aceitar convite")}
failureLabel={t("Não foi possível aceitar este convite. Ele pode ter vencido ou seu acesso foi revogado. Peça um novo link ao administrador.")}
/>
) : (
<AcceptInviteForm token={token} label={t("Aceitar convite")} pendingLabel={t("Confirmando…")} failureLabel={t("Não foi possível aceitar este convite. Ele pode ter vencido ou seu acesso foi revogado. Peça um novo link ao administrador.")} />
)}
</Shell>
);
}
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -122,10 +122,10 @@ export function SidebarContent({
// <img> 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
<img src={logo} alt={nome} className="h-7 w-auto max-w-[10rem] object-contain" />
<img src={logo} alt={nome} className="ml-5 h-[52px] w-[150px] object-contain" />
) : (
<span className={cn("font-semibold tracking-tight", collapsed && "sr-only")}>{nome}</span>
)}
+4 -1
View File
@@ -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
+2 -1
View File
@@ -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]
+14
View File
@@ -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"] }),
});
}
+47 -29
View File
@@ -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),
});
}
}
+7 -1
View File
@@ -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);
+12
View File
@@ -95,4 +95,16 @@ describe("apiClient", () => {
const headers = fetchMock.mock.calls[0]![1].headers as Record<string, string>;
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);
});
});
+11 -3
View File
@@ -9,6 +9,12 @@ export type RequestOpts = {
schema?: ZodSchema<unknown>;
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<string, string>;
signal?: AbortSignal;
};
@@ -123,7 +129,9 @@ async function request<T>(
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<T>(
}
// 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<T>(
}
// Network error / timeout — retry
lastError = err;
if (attempt < MAX_ATTEMPTS) {
if (attempt < maxAttempts) {
await sleep(backoffMs(attempt), opts.signal);
continue;
}
+17
View File
@@ -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
+15
View File
@@ -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)
+20
View File
@@ -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
@@ -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();
+8 -10
View File
@@ -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 }) => {
+21 -92
View File
@@ -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 <nao-responda@revenda.com.br>");
expect(formatFromAddress(config, "Vendas Turbo")).toBe(
"Vendas Turbo <nao-responda@revenda.com.br>",
);
// 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" <evil@x.com>\r\nBcc: vitima@y.com');
const { formatFromAddress } = await import("@/lib/email/smtp");
const sujo = formatFromAddress(config, 'Acme" <evil@x.com>\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 <nao-responda@revenda.com.br>");
});
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: "<p>x</p>" });
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: "<p>x</p>" });
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");
});
});
+100
View File
@@ -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'