mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
Merge remote-tracking branch 'origin/main' into pr1408-conserto-tela
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
impacto: nada_mudou
|
||||
secao: corrigido
|
||||
titulo: O contador de não lidas acompanha a leitura da conversa
|
||||
---
|
||||
|
||||
Abrir uma conversa não lida tirava o negrito dela, mas o número no topo continuava contando essa conversa: quem atende via 3 na aba Não lidas com uma delas já aberta na tela, e a conta só batia depois de recarregar a página. O aviso de "os números mudaram" saía para a lista e para a conversa aberta, e nunca para a contagem — que é uma família de consultas à parte, uma por filtro de tela, e por isso não é alcançada por nenhum desses dois avisos. Agora a leitura derruba o número na hora, e o mesmo vale para qualquer filtro que esteja na tela, não só o que estava aberto quando o defeito foi relatado.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
impacto: nada_mudou
|
||||
secao: corrigido
|
||||
titulo: A importação de contatos reconhece cabeçalho em espanhol
|
||||
---
|
||||
|
||||
A tela de importação de contatos, em espanhol, promete reconhecer as colunas "nombre, teléfono, email, cpf, nacimiento, tags", mas um CSV com cabeçalho "nombre;teléfono" falhava com "cabeçalho sem coluna de telefone nem e-mail". Agora o cabeçalho é reconhecido também em espanhol (nombre, apodo, correo, teléfono, móvil, fecha de nacimiento, cumpleaños…), com acento, maiúsculas e espaços como o Excel escreve. Para quem usa em português nada muda, e não exige ação de quem opera a instalação. Crédito: @JowaniOrantes.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
impacto: capacidade_nova
|
||||
secao: adicionado
|
||||
titulo: Entrar com o Google, sem senha, nas telas de entrar e de criar conta
|
||||
---
|
||||
|
||||
Quem usa Google Workspace já não precisa criar mais uma senha para começar:
|
||||
as telas de entrar e de criar conta ganharam o botão **Entrar com Google**. Ele
|
||||
funciona nos dois sentidos — entra quem já tem conta e cria a conta quem não
|
||||
tem —, sem tela intermediária e sem pedir confirmação por e-mail.
|
||||
|
||||
Alguns cuidados que valem para quem opera:
|
||||
|
||||
- Quem chega por um **convite** continua entrando na empresa que convidou, pelo
|
||||
Google também. Sem isso, a pessoa convidada ganharia uma empresa própria e um
|
||||
assistente de boas-vindas que não é dela.
|
||||
- Numa instalação de **cadastro apenas por convite**, o Google continua barrado
|
||||
para quem não tem convite — e continua liberado para quem já usa o sistema.
|
||||
- Quem tem **verificação em duas etapas** cadastrada continua sendo obrigado a
|
||||
confirmar o código. Seria fácil deixar a porta mais nova mais fraca que a
|
||||
antiga.
|
||||
- Para ligar o botão de verdade, o provedor Google precisa estar habilitado no
|
||||
projeto Supabase da instalação (Authentication → Providers). Com ele
|
||||
desligado, a tela diz exatamente isso, em vez de um erro genérico.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
impacto: nada_mudou
|
||||
secao: corrigido
|
||||
titulo: O espanhol da interface passa a soar como espanhol, e não como português traduzido
|
||||
---
|
||||
|
||||
O espanhol do produto seguia a sintaxe do português e trazia palavras que em espanhol significam outra coisa: "demanda" (que é ação judicial), "retorno" e "agendamiento". Agora a demanda é "caso", o agendamento é "cita", o retorno prometido pela IA é "seguimiento" e a pessoa da equipe que atende é "asesor", para não se confundir com o "agente" de IA. A ação "Faturar" passa a "Cerrar y cobrar", porque em espanhol do México "facturar" costuma significar emitir uma nota fiscal. Revisamos as 6.899 frases da tela: cerca de 1.580 foram reescritas em frases mais curtas e diretas, e a Agenda, o Radar de risco e o Financeiro leem-se como texto escrito em espanhol. Também traduzimos as descrições de cinco destinos do menu (entre eles "Dados externos" em Configurações) que apareciam em português. Para quem usa em português nada muda, e não exige ação de quem opera a instalação. Crédito: @JowaniOrantes.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
impacto: nada_mudou
|
||||
secao: alterado
|
||||
titulo: A lista de números marca qual deles é o canal oficial
|
||||
---
|
||||
|
||||
Em Conexões, o número conectado pela API oficial da Meta agora aparece com a
|
||||
etiqueta "API oficial" ao lado do nome. Antes não havia nada na tela
|
||||
distinguindo-o dos números pareados por código QR, e as duas conexões funcionam
|
||||
de jeitos diferentes: a oficial não tem QR para reescanear nem aparelho para
|
||||
deslogar, e as mensagens dela seguem as regras de modelo aprovado.
|
||||
|
||||
A lista continua mostrando TODOS os números da organização, inclusive o oficial:
|
||||
nada foi escondido nem filtrado. A etiqueta é informação, e o estado da conexão
|
||||
(Conectado, Caiu, …) segue no badge de sempre, ao lado dela.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
impacto: capacidade_nova
|
||||
secao: adicionado
|
||||
titulo: O euro passa a aparecer na lista de moedas
|
||||
---
|
||||
Quem opera em Portugal não encontrava a própria moeda em Configurações › Organização: a lista ia do kwanza ao dólar, sem o euro. Agora o euro aparece, e o catálogo e o total de cada etapa do funil o escrevem como em Portugal, `249,90 €`. O cartão e a ficha do negócio e o painel da conversa ainda escrevem o euro na convenção brasileira (`€ 249,90`). Os negócios que chegam pelo formulário de captação, pela importação de planilha ou pelo agente de IA passam a nascer na moeda da empresa. Antes, nasciam em real. A trava que impede o agente de prometer preço abaixo da tabela também passa a reconhecer valores em euro, inclusive com o milhar separado por espaço (`1 497,00 €`). O padrão de quem ainda não escolheu continua sendo o real. Crédito: @maclevison.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
impacto: nada_mudou
|
||||
secao: corrigido
|
||||
titulo: A importação de leads por planilha reconhece cabeçalho em espanhol
|
||||
---
|
||||
|
||||
Em espanhol, uma planilha de leads com cabeçalho "Nombre, Teléfono, Correo…" não era reconhecida: o nome e o telefone caíam em "Colunas que não reconheci", e sem nome do negócio nem do contato a importação era recusada. Agora o importador entende também o cabeçalho em espanhol (nombre, contacto, teléfono, móvil, correo, descripción, precio, origen…), com acento e caixa como o Excel escreve. Para quem usa em português nada muda, e não exige ação de quem opera a instalação. Crédito: @JowaniOrantes.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
impacto: nada_mudou
|
||||
secao: corrigido
|
||||
titulo: A importação do catálogo por planilha reconhece cabeçalho em espanhol
|
||||
---
|
||||
|
||||
Em espanhol, uma planilha de produtos com cabeçalho "Producto, Precio, Costo, Cantidad" não era reconhecida: sem uma coluna de nome e outra de preço que o importador entendesse, o arquivo era recusado na primeira tela do catálogo. Agora ele entende também o cabeçalho em espanhol (nombre, producto, descripción, precio, precio de venta, costo, coste, cantidad, existencias, stock…), com acento e caixa como o Excel escreve. Para quem usa em português nada muda, e não exige ação de quem opera a instalação. Crédito: @JowaniOrantes.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
impacto: nada_mudou
|
||||
secao: corrigido
|
||||
titulo: O MCP passa a contar token inválido e a barrar quem insiste
|
||||
---
|
||||
|
||||
O endereço que as ferramentas de IA usam para conversar com o sistema (`/api/mcp`) recusava token inválido sem contar a recusa. Cada recusa custava uma consulta ao banco e ninguém era barrado: dava para varrer tokens sem limite e de graça, e um token já revogado podia ser martelado de vários endereços ao mesmo tempo sem que nada reagisse.
|
||||
|
||||
Agora a recusa conta em dois lugares: por origem (30 recusas em 5 minutos) e pelo próprio valor apresentado (5 recusas em 5 minutos — a chave é o resumo do valor, nunca o valor em si). Estourado o teto, a resposta é `429`. Token válido em uso não entra na conta, e falha do banco — que é problema nosso, não de quem chamou — não tranca ninguém.
|
||||
|
||||
Contribuição de @webtecnica (#1447).
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
impacto: nada_mudou
|
||||
secao: corrigido
|
||||
titulo: Credencial de IA que falha na revalidação deixa de exibir a lista de modelos antiga
|
||||
---
|
||||
|
||||
Ao testar de novo uma credencial de IA, se o provedor recusasse a chave o sistema registrava o erro mas mantinha a lista de modelos da validação anterior. Na tela, a credencial aparecia com a mensagem de falha e, logo abaixo, a contagem de modelos de antes — parecendo pronta para uso quando já não era.
|
||||
|
||||
Agora a lista é zerada junto com o resultado da validação: quem olha vê o erro e nenhum modelo disponível, que é o estado real. Uma revalidação bem-sucedida continua gravando os modelos que o provedor devolveu.
|
||||
|
||||
Contribuição de @betoarts (#714).
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
impacto: nada_mudou
|
||||
secao: corrigido
|
||||
titulo: A suíte deixa de depender do proxy de modelo de quem a roda
|
||||
---
|
||||
|
||||
Nada muda para quem usa o CRM: a correção é na suíte de testes.
|
||||
|
||||
Quem contribui com um proxy de modelo configurado no shell (LiteLLM, um gateway
|
||||
da empresa, qualquer roteador local) via `tests/unit/gateway-destino-por-caminho`
|
||||
reprovar na própria máquina enquanto passava no CI — o teste afirma para onde a
|
||||
requisição vai, e os SDKs da Anthropic e da OpenAI leem `ANTHROPIC_BASE_URL` do
|
||||
ambiente por conta própria, apontando o destino para `localhost`.
|
||||
|
||||
O teste passa a isolar essas variáveis, e a devolvê-las depois.
|
||||
@@ -1052,6 +1052,7 @@ jobs:
|
||||
SPECS_PARTE_5: >-
|
||||
inbox-busca-e-filtros-dizem-a-verdade.spec.ts
|
||||
extensoes-da-instalacao.spec.ts
|
||||
admin-credencial-google.spec.ts
|
||||
agenda-primeira-pintura-no-fuso.spec.ts
|
||||
agenda-portao-de-hidratacao.spec.ts
|
||||
qa-l12-inbox.spec.ts
|
||||
|
||||
@@ -339,6 +339,23 @@ dentro do include do unit. Fixtures em `tests/fixtures/`, helpers em `tests/help
|
||||
em `tests/setup/vitest.setup.ts`. Determinismo é regra: teste que depende de ordem ou de rede
|
||||
quebra a suíte inteira.
|
||||
|
||||
**Locator de tela compartilhada é contrato da suíte, não detalhe do teste.** `getByRole("button",
|
||||
{ name: "Entrar" })` casa por **substring** — e `/entrar/i`, que era a forma do login, também: um
|
||||
segundo botão com essa palavra na mesma tela ("Entrar com Google") torna o locator ambíguo, e o
|
||||
Playwright **recusa clicar** (`strict mode violation`) em vez de escolher. O alcance não fica na
|
||||
tela: `/login` é a porta de quase toda spec. Medido em 2026-09-21, um botão a mais ali pôs as 5
|
||||
partes do `e2e` vermelhas — 320 violações do mesmo erro em 306 casos, 140 specs citadas no log.
|
||||
Quem acrescenta botão ou link numa tela já coberta assume os locators que já existem: ancore com
|
||||
`{ name: "Entrar", exact: true }`, forma que a suíte já usa 282× para outros rótulos, e meça antes
|
||||
de empurrar:
|
||||
|
||||
```bash
|
||||
git grep -nE "name: *(\"Entrar\"|'Entrar'|/entrar)" -- tests scripts | grep -vE 'exact: *true'
|
||||
```
|
||||
|
||||
O conserto é no locator, **nunca** no produto: esconder um botão real para agradar regex de teste
|
||||
troca um defeito de teste por um defeito de tela.
|
||||
|
||||
O `.env.e2e` é obrigatório e é recusado se apontar para Supabase que não seja `127.0.0.1`/
|
||||
`localhost` — a proteção existe porque sem ela a suíte rodaria contra produção (`pnpm e2e:env`
|
||||
gera o arquivo).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { EntrarComGoogle } from "@/components/auth/EntrarComGoogle";
|
||||
import { LoginForm } from "@/components/auth/LoginForm";
|
||||
import { branding } from "@/lib/branding";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
@@ -98,7 +99,47 @@ export default async function LoginPage({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
As duas recusas da entrada com Google, separadas de propósito: uma é
|
||||
falha da volta (o `code` não virou sessão), a outra é desistência de
|
||||
quem estava do outro lado. A mesma mensagem para as duas mandaria a
|
||||
pessoa "tentar de novo" quando ela só fechou a tela — e procurar
|
||||
defeito onde não há.
|
||||
*/}
|
||||
{error === "entrada_com_google" && (
|
||||
<div
|
||||
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{t(
|
||||
"Não foi possível concluir a entrada com o Google. Tente novamente — se acontecer de novo, entre com e-mail e senha.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error === "entrada_com_google_cancelada" && (
|
||||
<div
|
||||
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{t("A entrada com o Google foi cancelada antes de terminar. Nada mudou na sua conta.")}
|
||||
</div>
|
||||
)}
|
||||
{/* A terceira recusa da entrada com Google: a conta está confirmada, mas o
|
||||
acesso dela foi retirado. Não é convite inválido (não havia convite
|
||||
nenhum) nem falha do Google — é decisão de quem administra, e a tela
|
||||
diz exatamente isso, em vez de mandar a pessoa "tentar de novo". */}
|
||||
{error === "acesso_revogado" && (
|
||||
<div
|
||||
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{t(
|
||||
"O acesso desta conta foi retirado por quem administra o sistema — então não criamos uma empresa nova para você. Se o acesso deveria continuar, peça a quem administra para restaurá-lo; se você está entrando em outra equipe, peça um convite.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<LoginForm next={next} />
|
||||
<EntrarComGoogle next={next} />
|
||||
<div className="space-y-2 text-center text-sm">
|
||||
<p>
|
||||
<Link
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { EntrarComGoogle } from "@/components/auth/EntrarComGoogle";
|
||||
import { SignupForm } from "@/components/auth/SignupForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { branding } from "@/lib/branding";
|
||||
@@ -102,6 +103,9 @@ export default async function SignupPage({
|
||||
)}
|
||||
|
||||
<SignupForm convite={convite} />
|
||||
{/* O convite atravessa o Google na URL de retorno: sem ele, quem foi
|
||||
convidado e cria a conta com Google ganharia uma empresa própria. */}
|
||||
<EntrarComGoogle convite={convite?.token} />
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t("Já tem conta?")}{" "}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"use server";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { createClientDeEntradaComGoogle } from "@/lib/supabase/server";
|
||||
import { urlDeRetornoDoGoogle } from "@/lib/auth/entrada-com-google";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
export type SignInWithGoogleResult = {
|
||||
ok: false;
|
||||
/** `google_indisponivel` = o provedor não está habilitado nesta instalação. */
|
||||
error: "google_indisponivel" | "erro_inesperado";
|
||||
};
|
||||
|
||||
/**
|
||||
* Entrada com Google — a METADE DE IDA do OAuth.
|
||||
*
|
||||
* Roda no servidor de propósito. O verificador de PKCE que o Google vai cobrar
|
||||
* na volta é gravado em cookie pelo `createServerClient` (ver
|
||||
* `createClientDeEntradaComGoogle`, que é quem garante que esse cookie viaje na
|
||||
* navegação de volta). Feito no navegador com a chave anon, o verificador
|
||||
* ficaria no `localStorage` — lugar de onde o `/auth/callback` do servidor não
|
||||
* o lê, e o erro seria o mesmo `PKCE code verifier not found in storage`.
|
||||
*
|
||||
* ─── Por que sem `skipBrowserRedirect` ──────────────────────────────────────
|
||||
*
|
||||
* É tentador passar, já que não existe `window` aqui. Medido no auth-js 2.116.0
|
||||
* instalado: `signInWithOAuth` já guarda a navegação — `_handleProviderSignIn`
|
||||
* só chama `window.location.assign` sob `isBrowser() && !skipBrowserRedirect`.
|
||||
* Passar a opção teria efeito colateral: ela acrescenta
|
||||
* `skip_http_redirect=true` à URL do `/authorize` (auth-js, linha 4818), que é
|
||||
* exatamente a instrução "não redirecione o navegador" — o oposto do que
|
||||
* queremos numa página que acabou de receber um clique.
|
||||
*
|
||||
* ─── Por que não há limite de tentativas aqui ───────────────────────────────
|
||||
*
|
||||
* Esta chamada NÃO fala com o GoTrue: o auth-js monta a URL do `/authorize`
|
||||
* localmente (`_getUrlForProvider`) e devolve. Não há orçamento a gastar nem
|
||||
* conta a proteger — quem gasta é a volta, no `/auth/callback`, e lá o
|
||||
* `code` é de uso único e assinado pelo GoTrue.
|
||||
*
|
||||
* Em caso de erro, devolve discriminador para a tela mostrar (o Google pode não
|
||||
* estar habilitado na instalação). No sucesso, `redirect()` não retorna: o
|
||||
* navegador sai daqui direto para o Google.
|
||||
*/
|
||||
export async function signInWithGoogle(
|
||||
params: { next?: string; convite?: string } = {},
|
||||
): Promise<SignInWithGoogleResult> {
|
||||
const hdrs = await headers();
|
||||
const requestId = hdrs.get("x-request-id");
|
||||
const supabase = await createClientDeEntradaComGoogle();
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: "google",
|
||||
options: { redirectTo: urlDeRetornoDoGoogle(env.NEXT_PUBLIC_APP_URL, params) },
|
||||
});
|
||||
|
||||
if (error || !data?.url) {
|
||||
// "Unsupported provider: provider is not enabled" é o que o GoTrue responde
|
||||
// quando ninguém ligou o provedor Google no projeto — o operador precisa
|
||||
// saber que o conserto é na configuração, e não tentar de novo.
|
||||
const indisponivel = /provider is not enabled|unsupported provider/i.test(error?.message ?? "");
|
||||
|
||||
await audit({
|
||||
action: "auth.google_signin_failed",
|
||||
metadata: {
|
||||
motivo: indisponivel ? "provedor_indisponivel" : "url_ausente",
|
||||
reason: error?.message ?? "data.url ausente",
|
||||
},
|
||||
requestId,
|
||||
});
|
||||
|
||||
return { ok: false, error: indisponivel ? "google_indisponivel" : "erro_inesperado" };
|
||||
}
|
||||
|
||||
// Server-side redirect: os Set-Cookie do verificador de PKCE saem junto.
|
||||
redirect(data.url);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -316,10 +316,12 @@ export async function createLeadHandler(
|
||||
// no catálogo de produtos. Mesma função daquele conserto, pelo mesmo motivo
|
||||
// (uma leitura só, que não diverge entre caminhos de escrita).
|
||||
//
|
||||
// A leitura extra só acontece quando quem chamou NÃO mandou moeda — a REST
|
||||
// com `currency` no corpo, o import e o webhook passam direto. E ela não pode
|
||||
// derrubar a criação: `moedaDaOrganizacao` degrada para o padrão e deixa
|
||||
// rastro (console.error + Sentry) em vez de lançar.
|
||||
// A leitura extra só acontece quando quem chamou NÃO mandou moeda: a REST com
|
||||
// `currency` no corpo passa direto. O import por planilha e o webhook de
|
||||
// captação NÃO mandam — mandavam `"BRL"` em duro, e o lead de uma organização
|
||||
// em euro nascia em real — e caem aqui. Ela não pode derrubar a criação:
|
||||
// `moedaDaOrganizacao` degrada para o padrão e deixa rastro (console.error +
|
||||
// Sentry) em vez de lançar.
|
||||
const currency = input.currency ?? (await moedaDaOrganizacao(supabase, ctx.organization_id));
|
||||
|
||||
const serviceOrigin = ctx.serviceOrigin ?? await observeServiceOrigin(createAdminClient(), ctx.organization_id, input.contact_id ?? null);
|
||||
|
||||
@@ -244,7 +244,6 @@ export async function POST(req: NextRequest): Promise<Response> {
|
||||
description: linha.description,
|
||||
contact_id: contactId,
|
||||
value_cents: linha.value_cents,
|
||||
currency: "BRL",
|
||||
tags: linha.tags,
|
||||
source: linha.source,
|
||||
},
|
||||
|
||||
@@ -515,7 +515,6 @@ export async function POST(req: NextRequest, ctx: RouteCtx): Promise<NextRespons
|
||||
? respondiLeadTitle(respondiMapped)
|
||||
: (mapped.name ?? mapped.phone ?? mapped.email ?? "Lead sem nome"),
|
||||
contact_id: contactId,
|
||||
currency: "BRL",
|
||||
tags: [],
|
||||
source: "webhook",
|
||||
custom_fields: mapped.custom_fields,
|
||||
|
||||
@@ -137,8 +137,12 @@ export async function saveAgentDraftAction(
|
||||
// conferido depois, uma ordem inválida devolveria erro com a versão já
|
||||
// gravada; se fosse GRAVADO antes, um escopo inválido devolveria erro com o
|
||||
// nome já trocado — a lista mostrando o novo e o editor o velho.
|
||||
// `agentMcpPatchSchema` é a régua que a rota REST já usa: uma quarta régua
|
||||
// para o mesmo campo é o defeito seguinte.
|
||||
// `agentMcpPatchSchema` NÃO é a régua da rota REST (essa é `agentPatchSchema`,
|
||||
// em lib/ai/guardrails-schema.ts — mais estrita em name/description). É a régua
|
||||
// do cadastro do editor MCP, a mesma do formulário (AgentForm.tsx) e alinhada de
|
||||
// propósito com `agentMcpCreateSchema`, para criar e editar terem a mesma régua.
|
||||
// A afirmação de equivalência com o REST era falsa e ficou parada aqui até o
|
||||
// achado #532 medir a divergência.
|
||||
const cadastroParsed =
|
||||
cadastro === undefined ? null : agentMcpPatchSchema.safeParse(cadastro);
|
||||
if (cadastroParsed && !cadastroParsed.success) {
|
||||
|
||||
@@ -481,8 +481,11 @@ export function AgentForm(props: Props) {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit) {
|
||||
// A mesma régua do servidor, aqui, para o erro aparecer no campo em vez
|
||||
// de voltar como 500 depois de a versão já ter sido gravada.
|
||||
// A mesma régua do cadastro que a Server Action valida de novo
|
||||
// (_actions.ts, `agentMcpPatchSchema` — não a da rota REST, que é
|
||||
// `agentPatchSchema` e diverge em name/description), aqui só para o erro
|
||||
// aparecer no campo em vez de voltar como 500 depois de a versão já ter
|
||||
// sido gravada.
|
||||
const cadastro = agentMcpPatchSchema.safeParse(toCadastroPayload(form));
|
||||
if (!cadastro.success) {
|
||||
toast.error(t("Validação falhou."));
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("ProfileForm em espanhol", () => {
|
||||
it("com locale es, os rótulos vêm do dicionário", () => {
|
||||
renderForm("es", "es");
|
||||
expect(screen.getByText("Nombre completo")).toBeTruthy();
|
||||
expect(screen.getByText("Huso horario")).toBeTruthy();
|
||||
expect(screen.getByText("Zona horaria")).toBeTruthy();
|
||||
expect(screen.queryByText("Nome completo")).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { audit } from "@/lib/audit";
|
||||
import { aplicarConvite } from "@/lib/auth/aplicar-convite";
|
||||
import { decidirConviteDoSignup } from "@/lib/auth/convite-no-signup";
|
||||
import { ensureTenantForUser, vinculoAtivo } from "@/lib/auth/provision";
|
||||
import { modoDeCadastro } from "@/lib/auth/politica-de-cadastro";
|
||||
import { acessoFoiRevogado } from "@/lib/auth/vinculo-revogado";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
/**
|
||||
* GET /auth/callback — a volta da entrada com Google (issue #1388).
|
||||
*
|
||||
* O defeito que este arquivo existe para não deixar voltar: a volta do OAuth é
|
||||
* o ÚNICO ponto em que "entrar" e "criar conta" chegam juntos, sem e-mail no
|
||||
* meio para dizer qual é qual. Tratar todo mundo como cadastro novo tranca do
|
||||
* lado de fora quem já é de casa numa instalação `so_convite`; tratar todo mundo
|
||||
* como entrada abre organização para quem chegou sem convite. A bifurcação é o
|
||||
* VÍNCULO, e é ela que estes casos prendem.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn() }));
|
||||
vi.mock("@/lib/auth/aplicar-convite", () => ({ aplicarConvite: vi.fn() }));
|
||||
vi.mock("@/lib/auth/convite-no-signup", () => ({ decidirConviteDoSignup: vi.fn() }));
|
||||
vi.mock("@/lib/auth/provision", () => ({
|
||||
ensureTenantForUser: vi.fn(async () => ({ provisioned: true })),
|
||||
vinculoAtivo: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/auth/politica-de-cadastro", () => ({ modoDeCadastro: vi.fn(async () => "aberto") }));
|
||||
vi.mock("@/lib/auth/vinculo-revogado", () => ({ acessoFoiRevogado: vi.fn(async () => false) }));
|
||||
vi.mock("@/lib/audit", () => ({ audit: vi.fn(async () => undefined) }));
|
||||
vi.mock("@/lib/env", () => ({ env: { NEXT_PUBLIC_APP_URL: "http://localhost:3000" } }));
|
||||
|
||||
const USUARIO = { id: "11111111-1111-4111-8111-111111111111", email: "convidado@example.com" };
|
||||
const PAYLOAD = {
|
||||
invite_id: "22222222-2222-4222-8222-222222222222",
|
||||
email: "convidado@example.com",
|
||||
organization_id: "33333333-3333-4333-8333-333333333333",
|
||||
role: "manager",
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
|
||||
interface Cenario {
|
||||
/** o que `exchangeCodeForSession` devolve */
|
||||
troca: { data: { user: unknown } | null; error: { message: string } | null };
|
||||
/** fatores TOTP que a conta já tem verificados */
|
||||
fatores?: { id: string; status: string }[];
|
||||
}
|
||||
|
||||
function stubSupabase(c: Cenario) {
|
||||
return {
|
||||
auth: {
|
||||
exchangeCodeForSession: vi.fn(async () => c.troca),
|
||||
mfa: {
|
||||
listFactors: vi.fn(async () => ({ data: { totp: c.fatores ?? [] } })),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requisicao(qs: string) {
|
||||
return new NextRequest(`http://localhost:3000/auth/callback?${qs}`);
|
||||
}
|
||||
|
||||
/** O destino do redirect, sem o host — é o que o teste realmente afirma. */
|
||||
function destino(res: Response): string {
|
||||
const location = new URL(res.headers.get("location") ?? "");
|
||||
return location.pathname + location.search;
|
||||
}
|
||||
|
||||
async function comSupabase(c: Cenario) {
|
||||
vi.mocked(createClient).mockResolvedValue(
|
||||
stubSupabase(c) as unknown as Awaited<ReturnType<typeof createClient>>,
|
||||
);
|
||||
return await import("./route");
|
||||
}
|
||||
|
||||
describe("GET /auth/callback", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(aplicarConvite).mockResolvedValue({ ok: true, membershipId: "m1", mudou: true });
|
||||
vi.mocked(decidirConviteDoSignup).mockReturnValue({ tipo: "provisionar" });
|
||||
vi.mocked(vinculoAtivo).mockResolvedValue(null);
|
||||
vi.mocked(modoDeCadastro).mockResolvedValue("aberto");
|
||||
vi.mocked(acessoFoiRevogado).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("conta nova com convite na URL: grava o vínculo e entra no app, sem empresa nova", async () => {
|
||||
const { GET } = await comSupabase({ troca: { data: { user: USUARIO }, error: null } });
|
||||
vi.mocked(decidirConviteDoSignup).mockReturnValue({
|
||||
tipo: "convite",
|
||||
token: "tok",
|
||||
payload: PAYLOAD,
|
||||
} as ReturnType<typeof decidirConviteDoSignup>);
|
||||
|
||||
const res = await GET(requisicao("code=abc&convite=tok"));
|
||||
|
||||
// O convite da URL precisa CHEGAR à decisão: sem isto, quem foi convidado
|
||||
// e entrou com Google ganha uma organização própria.
|
||||
expect(vi.mocked(decidirConviteDoSignup)).toHaveBeenCalledWith(USUARIO, "tok");
|
||||
expect(vi.mocked(aplicarConvite)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: USUARIO.id, payload: PAYLOAD }),
|
||||
);
|
||||
expect(destino(res)).toBe("/app");
|
||||
expect(vi.mocked(ensureTenantForUser)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("quem JÁ tem vínculo entra: vai para o destino pedido, e a política de cadastro não o alcança", async () => {
|
||||
const { GET } = await comSupabase({ troca: { data: { user: USUARIO }, error: null } });
|
||||
vi.mocked(vinculoAtivo).mockResolvedValue("org-existente");
|
||||
// Instalação fechada: quem já é de casa continua entrando.
|
||||
vi.mocked(modoDeCadastro).mockResolvedValue("so_convite");
|
||||
|
||||
const res = await GET(requisicao("code=abc&next=%2Fapp%2Finbox"));
|
||||
|
||||
expect(destino(res)).toBe("/app/inbox");
|
||||
expect(vi.mocked(decidirConviteDoSignup)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(ensureTenantForUser)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("conta nova sem convite em instalação so_convite: recusa pela política, sem provisionar", async () => {
|
||||
const { GET } = await comSupabase({ troca: { data: { user: USUARIO }, error: null } });
|
||||
vi.mocked(modoDeCadastro).mockResolvedValue("so_convite");
|
||||
|
||||
const res = await GET(requisicao("code=abc"));
|
||||
|
||||
expect(destino(res)).toBe("/login?error=cadastro_por_convite");
|
||||
expect(vi.mocked(ensureTenantForUser)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("convite que não vale: falha FECHADA — não provisiona e diz o motivo", async () => {
|
||||
const { GET } = await comSupabase({ troca: { data: { user: USUARIO }, error: null } });
|
||||
vi.mocked(decidirConviteDoSignup).mockReturnValue({
|
||||
tipo: "recusar",
|
||||
motivo: "email_divergente",
|
||||
});
|
||||
|
||||
const res = await GET(requisicao("code=abc&convite=de-outra-pessoa"));
|
||||
|
||||
expect(destino(res)).toBe("/login?error=convite_invalido");
|
||||
expect(vi.mocked(ensureTenantForUser)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("conta nova sem convite em instalação aberta: provisiona e entra no onboarding", async () => {
|
||||
const { GET } = await comSupabase({ troca: { data: { user: USUARIO }, error: null } });
|
||||
|
||||
const res = await GET(requisicao("code=abc"));
|
||||
|
||||
expect(vi.mocked(ensureTenantForUser)).toHaveBeenCalledWith(USUARIO, { source: "signup" });
|
||||
expect(destino(res)).toBe("/onboarding/welcome");
|
||||
});
|
||||
|
||||
it("quem tem TOTP verificado não entra sem o segundo fator", async () => {
|
||||
const { GET } = await comSupabase({
|
||||
troca: { data: { user: USUARIO }, error: null },
|
||||
fatores: [{ id: "factor-1", status: "verified" }],
|
||||
});
|
||||
|
||||
const res = await GET(requisicao("code=abc&next=%2Fapp%2Finbox"));
|
||||
|
||||
expect(destino(res)).toBe("/login/mfa?factor=factor-1&next=%2Fapp%2Finbox");
|
||||
expect(vi.mocked(ensureTenantForUser)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("code que não vira sessão: nenhum provisionamento, e a tela de login explica", async () => {
|
||||
const { GET } = await comSupabase({
|
||||
troca: { data: null, error: { message: "PKCE code verifier not found in storage" } },
|
||||
});
|
||||
|
||||
const res = await GET(requisicao("code=abc"));
|
||||
|
||||
expect(destino(res)).toBe("/login?error=entrada_com_google");
|
||||
expect(vi.mocked(ensureTenantForUser)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(vinculoAtivo)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("desistência no Google: mensagem própria, e não se troca code nenhum", async () => {
|
||||
const { GET } = await comSupabase({ troca: { data: null, error: null } });
|
||||
|
||||
const res = await GET(requisicao("error=access_denied&error_description=denied"));
|
||||
|
||||
expect(destino(res)).toBe("/login?error=entrada_com_google_cancelada");
|
||||
expect(vi.mocked(createClient)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sem code nenhum: recusa em vez de tela em branco", async () => {
|
||||
const { GET } = await comSupabase({ troca: { data: null, error: null } });
|
||||
|
||||
const res = await GET(requisicao(""));
|
||||
|
||||
expect(destino(res)).toBe("/login?error=entrada_com_google");
|
||||
expect(vi.mocked(createClient)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("banco fora no provisionamento: a sessão JÁ está firme, então manda pro /get-started", async () => {
|
||||
const { GET } = await comSupabase({ troca: { data: { user: USUARIO }, error: null } });
|
||||
vi.mocked(ensureTenantForUser).mockRejectedValueOnce(new Error("sem banco"));
|
||||
|
||||
const res = await GET(requisicao("code=abc"));
|
||||
|
||||
expect(destino(res)).toBe("/get-started");
|
||||
});
|
||||
|
||||
it("membro com acesso revogado não vira admin de tenant novo: para na porta e diz o motivo", async () => {
|
||||
const { GET } = await comSupabase({ troca: { data: { user: USUARIO }, error: null } });
|
||||
vi.mocked(acessoFoiRevogado).mockResolvedValue(true);
|
||||
|
||||
const res = await GET(requisicao("code=abc"));
|
||||
|
||||
// `vinculoAtivo` não distingue "nunca pertenceu" de "teve o acesso
|
||||
// retirado" — e é essa diferença que impede a revogação de virar
|
||||
// organização nova com `role: "admin"`.
|
||||
expect(destino(res)).toBe("/login?error=acesso_revogado");
|
||||
expect(vi.mocked(ensureTenantForUser)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(aplicarConvite)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(audit)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "auth.signup_provision_recusado",
|
||||
actorUserId: USUARIO.id,
|
||||
metadata: expect.objectContaining({ motivo: "acesso_revogado" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("a guarda do revogado vem ANTES da decisão de convite: o motivo auditado é o verdadeiro", async () => {
|
||||
const { GET } = await comSupabase({ troca: { data: { user: USUARIO }, error: null } });
|
||||
vi.mocked(acessoFoiRevogado).mockResolvedValue(true);
|
||||
|
||||
const res = await GET(requisicao("code=abc&convite=de-outra-pessoa"));
|
||||
|
||||
// Fora desta ordem a rota auditaria `convite_invalido` — motivo que não é a
|
||||
// verdade sobre o que aconteceu com quem foi revogado.
|
||||
expect(vi.mocked(decidirConviteDoSignup)).not.toHaveBeenCalled();
|
||||
expect(destino(res)).toBe("/login?error=acesso_revogado");
|
||||
});
|
||||
|
||||
it("leitura do vínculo falhou: FALHA FECHADA — não provisiona e a tela diz o motivo", async () => {
|
||||
const { GET } = await comSupabase({ troca: { data: { user: USUARIO }, error: null } });
|
||||
vi.mocked(vinculoAtivo).mockRejectedValueOnce(new Error("sem banco"));
|
||||
|
||||
const res = await GET(requisicao("code=abc"));
|
||||
|
||||
// "não consegui ler" não é "não há vínculo": a rota não pode seguir para o
|
||||
// provisionamento por causa de um tropeço de leitura.
|
||||
expect(destino(res)).toBe("/login?error=entrada_com_google");
|
||||
expect(vi.mocked(ensureTenantForUser)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(decidirConviteDoSignup)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ramo anônimo não escreve no rastro: sem `code` não há linha de auditoria", async () => {
|
||||
const { GET } = await comSupabase({ troca: { data: null, error: null } });
|
||||
|
||||
const res = await GET(requisicao(""));
|
||||
|
||||
// Rota pública: um GET por requisição de qualquer anônimo não pode virar
|
||||
// escrita em `api_audit_log`.
|
||||
expect(destino(res)).toBe("/login?error=entrada_com_google");
|
||||
expect(vi.mocked(audit)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("desistência no Google também não escreve no rastro, mesmo com texto cru gigante na URL", async () => {
|
||||
const { GET } = await comSupabase({ troca: { data: null, error: null } });
|
||||
|
||||
const res = await GET(requisicao(`error=access_denied&error_description=${"x".repeat(4000)}`));
|
||||
|
||||
expect(destino(res)).toBe("/login?error=entrada_com_google_cancelada");
|
||||
expect(vi.mocked(audit)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { ensureTenantForUser, vinculoAtivo } from "@/lib/auth/provision";
|
||||
import { acessoFoiRevogado } from "@/lib/auth/vinculo-revogado";
|
||||
import { decidirConviteDoSignup } from "@/lib/auth/convite-no-signup";
|
||||
import { modoDeCadastro } from "@/lib/auth/politica-de-cadastro";
|
||||
import { aplicarConvite } from "@/lib/auth/aplicar-convite";
|
||||
import { safeNext } from "@/lib/auth/safe-next";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* GET /auth/callback — a VOLTA da entrada com Google (issue #1388).
|
||||
*
|
||||
* O GoTrue devolve o navegador aqui com `?code=…` depois de o Google confirmar
|
||||
* a identidade. Aqui o `code` vira SESSÃO, no servidor, na mesma requisição que
|
||||
* gravou o cookie do verificador de PKCE — que é a razão de a metade de ida
|
||||
* (`signInWithGoogle`) existir no servidor e de o cookie dela ser `Lax`
|
||||
* (`createClientDeEntradaComGoogle`).
|
||||
*
|
||||
* ─── Por que a troca não passa `flowId` para o auth-js ──────────────────────
|
||||
*
|
||||
* O auth-js 2.116.0 grava o verificador em DOIS lugares: um slot por fluxo
|
||||
* (`…-flow-<id>-code-verifier`) e a chave fixa de sempre (`…-code-verifier`),
|
||||
* esta última de propósito — está escrito no `storePKCEVerifier` que a escrita
|
||||
* dupla cobre "trocas que não conseguem identificar o próprio fluxo (SDKs
|
||||
* antigos, redirects sem o parâmetro de fluxo)". O servidor é exatamente esse
|
||||
* caso: sem `window`, o `_exchangeCodeForSession` não lê o parâmetro da URL, e
|
||||
* com `flowId` explícito ele passaria a ler SÓ o slot. Sem ele, cai na chave
|
||||
* fixa — que existe. Limite conhecido: dois consentimentos abertos em paralelo
|
||||
* no mesmo navegador disputam a chave fixa; o mais novo ganha.
|
||||
*
|
||||
* ─── O que a volta decide ───────────────────────────────────────────────────
|
||||
*
|
||||
* Este é o ÚNICO ponto em que "entrar" e "criar conta" chegam juntos, sem o
|
||||
* e-mail no meio para dizer qual dos dois é. A bifurcação é o VÍNCULO
|
||||
* (`user_organizations`), não uma heurística de data:
|
||||
*
|
||||
* - quem JÁ tem vínculo está ENTRANDO — vai para o destino pedido, e as travas
|
||||
* de cadastro não o alcançam (numa instalação `so_convite`, barrar aqui
|
||||
* trancaria do lado de fora todo mundo que já usa o sistema);
|
||||
* - quem NÃO tem vínculo está CRIANDO CONTA — e aí valem as mesmas travas do
|
||||
* `/auth/confirm`: convite, política de cadastro e provisionamento.
|
||||
*
|
||||
* Tudo o mais é cópia deliberada do miolo de `app/auth/confirm/route.ts`. Não
|
||||
* foi extraído para um módulo comum porque os dois caminhos não são o mesmo
|
||||
* caminho: lá já se sabe que houve confirmação de e-mail (e todo mundo entra no
|
||||
* onboarding), aqui há duas populações. O que os dois compartilham de verdade —
|
||||
* `decidirConviteDoSignup`, `aplicarConvite`, `ensureTenantForUser` — já está
|
||||
* compartilhado.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = request.nextUrl;
|
||||
const code = url.searchParams.get("code");
|
||||
const next = url.searchParams.get("next");
|
||||
const convite = url.searchParams.get("convite");
|
||||
const requestId = request.headers.get("x-request-id");
|
||||
|
||||
// NUNCA usar url.origin aqui: é derivado do header Host, que o proxy/container
|
||||
// pode entregar como o bind interno (ex.: 0.0.0.0:3000) em vez do domínio
|
||||
// público — o link de recovery quebra silenciosamente para o usuário final.
|
||||
const redirectTo = (path: string) => NextResponse.redirect(new URL(path, env.NEXT_PUBLIC_APP_URL));
|
||||
|
||||
// O Google devolve `error=access_denied` quando a pessoa fecha a tela de
|
||||
// consentimento. Não é falha do sistema, e tratar como falha manda a pessoa
|
||||
// procurar defeito onde não há — mas também não é sucesso: sem esta linha, a
|
||||
// tela de login ficaria em branco, sem dizer nada.
|
||||
//
|
||||
// SEM linha de auditoria aqui — nem no ramo abaixo. Estes dois ramos são o
|
||||
// ponto mais exposto da rota: ela está em `PUBLIC_PATHS` (ancorada) e o `error`
|
||||
// é texto cru de quem chama. Medido na revisão: `?error=<4000 caracteres>`
|
||||
// grava 4.045 bytes em `metadata.reason` (teto de 14.000), e um GET por
|
||||
// requisição de qualquer anônimo grava 1 linha em `api_audit_log` — tabela
|
||||
// append-only com piso de expurgo de 90 dias. A doutrina do irmão desta rota
|
||||
// (`app/api/v1/agenda/google/callback/route.ts`, o comentário antes do audit)
|
||||
// é a mesma e vale aqui: auditoria só DEPOIS do gate, quando quem chama já
|
||||
// provou ser o dono do verificador de PKCE. Quem chega sem `code` não provou
|
||||
// nada; a tela de login diz o que aconteceu, e o rastro não recebe a escrita
|
||||
// ilimitada.
|
||||
const erroDoProvedor = url.searchParams.get("error");
|
||||
if (erroDoProvedor) {
|
||||
return redirectTo("/login?error=entrada_com_google_cancelada");
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return redirectTo("/login?error=entrada_com_google");
|
||||
}
|
||||
|
||||
// O cliente de sempre (jar Strict): o verificador já viajou até aqui, e é este
|
||||
// que grava o cookie de SESSÃO.
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase.auth.exchangeCodeForSession(code);
|
||||
|
||||
if (error || !data?.user) {
|
||||
await audit({
|
||||
action: "auth.google_signin_failed",
|
||||
metadata: {
|
||||
motivo: "troca_do_code_falhou",
|
||||
// Texto de terceiro indo para uma tabela append-only: teto explícito, em
|
||||
// vez de confiar no tamanho que o provedor decidir mandar.
|
||||
reason: (error?.message ?? "no_user").slice(0, 200),
|
||||
},
|
||||
requestId,
|
||||
});
|
||||
return redirectTo("/login?error=entrada_com_google");
|
||||
}
|
||||
|
||||
const usuario = data.user;
|
||||
|
||||
// O Google autentica com dois fatores (e a conta pode tê-los). O login por
|
||||
// senha NÃO deixa passar quem tem TOTP verificado sem o segundo fator
|
||||
// (`signInWithPassword.ts:88`), e a entrada com Google tem de ter a mesma
|
||||
// força — senão vira a porta mais fraca do produto, e o fator que a pessoa
|
||||
// cadastrou deixa de valer em qualquer navegador novo.
|
||||
const { data: fatores } = await supabase.auth.mfa.listFactors();
|
||||
const totpVerificado = fatores?.totp?.find((f) => f.status === "verified");
|
||||
if (totpVerificado) {
|
||||
const params = new URLSearchParams({ factor: totpVerificado.id, next: safeNext(next, "/app") });
|
||||
return redirectTo(`/login/mfa?${params}`);
|
||||
}
|
||||
|
||||
// ENTRADA: já existe vínculo. Provisionar ou reaplicar convite aqui seria
|
||||
// refazer trabalho que já está feito — e recusar pelo modo de cadastro
|
||||
// trancaria do lado de fora quem já é de casa.
|
||||
//
|
||||
// FALHA FECHADA: `vinculoAtivo` lança quando não conseguiu LER, e `null`
|
||||
// continua querendo dizer só "não há vínculo". Sem este catch, um tropeço de
|
||||
// leitura cai no mesmo `null` do primeiro acesso e o resto da rota provisiona
|
||||
// organização nova para quem já tinha uma. A sessão já está firme aqui, então
|
||||
// a saída é a tela de login com o motivo — não um 500.
|
||||
let organizacaoId: string | null;
|
||||
try {
|
||||
organizacaoId = await vinculoAtivo(usuario.id);
|
||||
} catch (e) {
|
||||
await audit({
|
||||
action: "auth.google_signin_failed",
|
||||
actorUserId: usuario.id,
|
||||
metadata: {
|
||||
motivo: "leitura_do_vinculo_falhou",
|
||||
reason: e instanceof Error ? e.message.slice(0, 200) : "erro_desconhecido",
|
||||
},
|
||||
requestId,
|
||||
});
|
||||
return redirectTo("/login?error=entrada_com_google");
|
||||
}
|
||||
|
||||
if (organizacaoId) {
|
||||
await audit({
|
||||
action: "auth.login_success",
|
||||
actorUserId: usuario.id,
|
||||
metadata: { provider: "google" },
|
||||
requestId,
|
||||
});
|
||||
return redirectTo(safeNext(next, "/app"));
|
||||
}
|
||||
|
||||
// TERCEIRA população, e ela não estava no desenho: quem TEVE organização e
|
||||
// perdeu o acesso. `vinculoAtivo` só enxerga vínculo vivo (`.is("revoked_at",
|
||||
// null)`), então uma revogação chega aqui parecendo primeiro acesso — e numa
|
||||
// instalação aberta sairia com ORGANIZAÇÃO NOVA, `role: "admin"`, virando um
|
||||
// jeito de a revogação criar tenant em vez de encerrá-lo. A revogação não
|
||||
// apaga o auth user (o `revoke` só carimba `revoked_at`), então este é o único
|
||||
// ponto onde a porta nova pode ser fechada.
|
||||
//
|
||||
// A guarda já existe no repo e estava sendo esquecida só nesta porta: a MESMA
|
||||
// chamada, na MESMA posição do `recoverOrganization.ts:86` — depois de saber
|
||||
// que não há vínculo vivo e ANTES de decidir o convite. A posição é parte do
|
||||
// conserto: fora desta ordem o motivo auditado sairia como `convite_invalido`,
|
||||
// que não é a verdade sobre o que aconteceu com quem foi revogado.
|
||||
if (await acessoFoiRevogado(usuario.id)) {
|
||||
await audit({
|
||||
action: "auth.signup_provision_recusado",
|
||||
actorUserId: usuario.id,
|
||||
metadata: { motivo: "acesso_revogado", provider: "google" },
|
||||
requestId,
|
||||
});
|
||||
return redirectTo("/login?error=acesso_revogado");
|
||||
}
|
||||
|
||||
// CADASTRO: sem vínculo, este é um primeiro acesso. Daqui para baixo é o
|
||||
// mesmo miolo do `/auth/confirm`, e pelas mesmas razões — o comentário de lá
|
||||
// explica cada trava.
|
||||
const decisao = decidirConviteDoSignup(usuario, convite);
|
||||
|
||||
if (decisao.tipo === "recusar") {
|
||||
await audit({
|
||||
action: "auth.signup_provision_recusado",
|
||||
actorUserId: usuario.id,
|
||||
metadata: { motivo: decisao.motivo, provider: "google" },
|
||||
requestId,
|
||||
});
|
||||
return redirectTo("/login?error=convite_invalido");
|
||||
}
|
||||
|
||||
if (decisao.tipo === "convite") {
|
||||
const aceite = await aplicarConvite({
|
||||
userId: usuario.id,
|
||||
payload: decisao.payload,
|
||||
requestId,
|
||||
});
|
||||
if (aceite.ok) return redirectTo("/app");
|
||||
|
||||
// Convite revogado, ou banco fora: a tela de aceite continua existindo e
|
||||
// sabe explicar cada caso.
|
||||
return redirectTo(`/team/accept-invite/${decisao.token}`);
|
||||
}
|
||||
|
||||
// A trava da política de cadastro, depois de `decidirConviteDoSignup` de
|
||||
// propósito: quem tem convite válido já saiu acima, então esta guarda só
|
||||
// alcança quem chegou sem convite nenhum.
|
||||
if ((await modoDeCadastro()) === "so_convite") {
|
||||
await audit({
|
||||
action: "auth.signup_provision_recusado",
|
||||
actorUserId: usuario.id,
|
||||
metadata: { motivo: "somente_convite", provider: "google" },
|
||||
requestId,
|
||||
});
|
||||
return redirectTo("/login?error=cadastro_por_convite");
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureTenantForUser(usuario, { source: "signup" });
|
||||
} catch (e) {
|
||||
await audit({
|
||||
action: "auth.signup_provision_failed",
|
||||
actorUserId: usuario.id,
|
||||
metadata: { reason: e instanceof Error ? e.message : String(e), provider: "google" },
|
||||
requestId,
|
||||
});
|
||||
// A sessão JÁ está firmada. Mandar para `/login` deixava a pessoa logada e
|
||||
// sem organização, sem caminho de volta — ver `recoverOrganization.ts`.
|
||||
return redirectTo("/get-started");
|
||||
}
|
||||
|
||||
void audit({
|
||||
action: "auth.signup_confirmed",
|
||||
actorUserId: usuario.id,
|
||||
metadata: { provider: "google" },
|
||||
requestId,
|
||||
});
|
||||
|
||||
return redirectTo("/onboarding/welcome");
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
import { useT } from "@/hooks/i18n/useT";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { signInWithGoogle } from "@/app/actions/auth/signInWithGoogle";
|
||||
|
||||
/**
|
||||
* O botão "Entrar com Google" — o mesmo no login e no cadastro.
|
||||
*
|
||||
* O cadastro passa `convite` porque a volta não tem como saber que a pessoa
|
||||
* veio de um convite: o Google não devolve nada nosso além do que pusermos na
|
||||
* URL de retorno (ver `lib/auth/entrada-com-google.ts`).
|
||||
*
|
||||
* No sucesso nada volta para cá — a server action redireciona o navegador para
|
||||
* o Google. O que pode voltar é a recusa, e ela é mostrada aqui mesmo, embaixo
|
||||
* do botão, para não perder o que a pessoa já tinha digitado no formulário.
|
||||
*/
|
||||
export function EntrarComGoogle({ next, convite }: { next?: string; convite?: string }) {
|
||||
const t = useT();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [erro, setErro] = useState<string | null>(null);
|
||||
|
||||
const onClick = () => {
|
||||
setErro(null);
|
||||
startTransition(async () => {
|
||||
const res = await signInWithGoogle({ next, convite });
|
||||
// `res` indefinido = o redirect do sucesso aconteceu (ele lança).
|
||||
if (!res) return;
|
||||
|
||||
setErro(
|
||||
res.error === "google_indisponivel"
|
||||
? t(
|
||||
"O Google não está habilitado nesta instalação. Entre com e-mail e senha, ou peça a quem administra para habilitá-lo.",
|
||||
)
|
||||
: t("Não foi possível falar com o Google agora. Tente novamente em instantes."),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs uppercase tracking-wide text-muted-foreground">{t("ou")}</span>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
{erro && (
|
||||
<div
|
||||
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{erro}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
onClick={onClick}
|
||||
>
|
||||
{isPending ? t("Abrindo o Google...") : t("Entrar com Google")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
Warning,
|
||||
} from "@/lib/ui/icons";
|
||||
import { lerEstadoDoCanal } from "@/lib/channels/estado";
|
||||
import { fonteDeTemplates } from "@/lib/channels/templates-fonte";
|
||||
import { useT } from "@/hooks/i18n/useT";
|
||||
|
||||
type Variant = "success" | "warning" | "error" | "neutral";
|
||||
@@ -89,6 +90,21 @@ function dependeDoTransporte(c: ChannelSession): boolean {
|
||||
return Boolean(c.waha_session_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Este número fala pelo canal OFICIAL?" — perguntado pela FONTE das definições
|
||||
* do canal, que é o único lugar do repo que diz `oficial` para uma linha de
|
||||
* `channel_sessions` sem que a tela precise nomear provider nenhum.
|
||||
*
|
||||
* ⚠️ `!dependeDoTransporte(c)` responderia outra pergunta — "não tem sessão no
|
||||
* transporte" — e a diferença não é acadêmica: um número pareado por QR recém
|
||||
* criado, ainda sem nome de sessão, ganharia a etiqueta de oficial. Ali o erro
|
||||
* de pecar por excesso só escondia um botão (está escrito acima); aqui ele
|
||||
* AFIRMA à pessoa que opera algo falso sobre o número dela.
|
||||
*/
|
||||
function ehCanalOficial(c: ChannelSession): boolean {
|
||||
return fonteDeTemplates(c.provider) === "oficial";
|
||||
}
|
||||
|
||||
/** "3 conversas" / "1 conversa" — ou nada, quando não há o que contar. */
|
||||
function contar(
|
||||
n: number,
|
||||
@@ -374,6 +390,11 @@ export function ConnectionsClient({ wahaConfigured }: { wahaConfigured: boolean
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone size={16} className="text-muted-foreground" aria-hidden />
|
||||
<span className="truncate text-sm font-medium">{channelLabel(c, t)}</span>
|
||||
{ehCanalOficial(c) && (
|
||||
<Badge variant="default" className="shrink-0">
|
||||
{t("API oficial")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{c.phone_number && c.display_name && (
|
||||
<p className="mt-0.5 font-mono text-xs text-muted-foreground">
|
||||
|
||||
@@ -573,7 +573,7 @@ uma, para a asserção poder ser sobre o CONJUNTO DE NOMES e não sobre a contag
|
||||
| J13.7 | A ida ao Google seleciona os pendentes (o filtro antigo devolvia HTTP 400) | **PASS** — medido contra o PostgREST real do ambiente e2e: filtro antigo `400 / 22007`, filtro novo `200` com as linhas pendentes |
|
||||
| J13.8 | Sincronizar tira a linha da fila, e editar recoloca (o laço dos dois relógios) | **PASS** — medido no Postgres real: `true` → `false` com delta `00:00:00` → `true` |
|
||||
| J13.9 | A credencial do Google não é servida pelo PostgREST | **PASS** — `anon` recebe `42501 permission denied`; `service_role` recebe 200 (controle positivo) |
|
||||
| J13.10 | Cadastrar a credencial do Google pela tela do admin | **NÃO EXERCITADO** — a tela e a server action existem e o `next build` passa, mas o ambiente e2e não tem a chave mestra de cifra semeada (`fn_encrypt_oauth` levanta `NUVEMSHOP_OAUTH_ENCRYPTION_KEY ausente`), que é justamente o caminho em que a action RECUSA gravar. Falta o caso pela tela com a chave presente |
|
||||
| J13.10 | Cadastrar a credencial do Google pela tela do admin | **PASS** (issue #370) — `admin-credencial-google.spec.ts`, contra o app real. O CI grava a chave mestra de cifra no ambiente do e2e desde `.github/workflows/e2e.yml` (o que faltava quando esta linha foi escrita "NÃO EXERCITADO"). Prova: dono cadastra em `/admin/google`, o `client_secret` NÃO volta ao navegador nem recarregando nem no HTML servido, o cartão da Agenda para de pedir SSH e passa a oferecer "Conectar Google", e admin de tenant é barrado (`redirect` para `/admin/forbidden` antes da página rodar). Evidência: `evidence/admin-credencial-google/1-nao-cadastrada.png`, `evidence/admin-credencial-google/2-cadastrada-segredo-nao-volta.png`, `evidence/admin-credencial-google/3-cartao-da-agenda-oferece-conectar.png` |
|
||||
| J13.11 | Compromisso do Google que começa antes do período desenhado aparece na grade, fatiado na borda | **NÃO COBERTO** — medido só por unidade sobre dublê do cliente Supabase (`tests/unit/agenda-recorte-do-google-atravessa-o-limite.test.ts`); falta prova pela tela num ambiente com Google conectado. ⚠️ O conserto morde na BORDA do período que a tela desenha (virada da semana na visão Semana, do mês na visão Mês, meia-noite na visão Dia). Dentro do período desenhado a grade continua atribuindo o bloco só à coluna do dia em que ele COMEÇA (`components/agenda/GradeDaAgenda.tsx`, `isSameDay(comeca, dia)`) — essa metade é item próprio |
|
||||
| J13.12 | Agendamento INTERNO que atravessa a meia-noite aparece na janela do dia seguinte | **NÃO COBERTO, e o defeito é conhecido** — `listaAgendamentos` recorta por começo e não por interseção (`lib/agenda/consulta.ts`, `.gte("starts_at", de).lt("starts_at", ate)`), enquanto `coletaOQueOcupa` no mesmo arquivo já usa interseção: mesma discordância tela↔motor da #525, do lado interno. Não consertado junto porque `listaAgendamentos` também alimenta a ferramenta MCP do agente (`lib/mcp/tools/agendamento.ts`) — mudar o recorte muda o que o agente enxerga, e isso é decisão de contrato
|
||||
| J13.13 | ⚠️ **`viewer`/`agent` continuam sem ver a ocupação do Google do COLEGA na grade** | **NÃO COBERTO, e o defeito é conhecido** — a leitura da tela é pela SESSÃO, com o embed `calendar_connections!inner` (`lib/agenda/ocupacao-externa.ts`), e a RLS `calendar_connections_dono_ou_manager_read` (`supabase/baseline.sql`) só libera `user_id = auth.uid()` ou `fn_role_at_least(org,'manager')`. O motor (`fn_agenda_ocupacao_google_do_dono`, migration 0260) é `security definer` e entrega a ocupação a TODO membro: para esses dois papéis a tela desenha livre todo compromisso do colega enquanto a marcação recusa. É a metade da #525 que o #915 **não** fecha — ele fecha a FRONTEIRA do recorte, não o PAPEL de quem olha (resíduo da #879). O dublê de `tests/unit/agenda-recorte-do-google-atravessa-o-limite.test.ts` não modela papel nem RLS, então a suíte não pode enxergar isto. Fechar é decisão de produto sobre QUEM vê |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
@@ -22,6 +22,13 @@ export function useMarkAsRead(conversationId: string | null, unread: number) {
|
||||
onSuccess: (_data, id) => {
|
||||
qc.invalidateQueries({ queryKey: ["conversations"] });
|
||||
qc.invalidateQueries({ queryKey: ["conversation", id] });
|
||||
// O contador do topo vive em `["conversation-counts", orgId, sufixo]`
|
||||
// (useConversationCounts). O casamento por prefixo do react-query compara
|
||||
// elemento a elemento: nem `["conversations"]` nem `["conversation", id]`
|
||||
// alcançam essa família — era por isso que o negrito sumia e o número
|
||||
// ficava parado até recarregar a página. Invalidar a família inteira
|
||||
// acompanha também os sufixos que não estão na tela agora.
|
||||
qc.invalidateQueries({ queryKey: ["conversation-counts"] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,11 @@ export function useResumeAiAttendance() {
|
||||
onSuccess: (_data, args) => {
|
||||
qc.invalidateQueries({ queryKey: ["conversations"] });
|
||||
qc.invalidateQueries({ queryKey: ["conversation", args.conversation_id] });
|
||||
// Irmão do par: `usePauseAiAttendance` já invalidava a contagem, e devolver
|
||||
// a conversa ao automático muda o mesmo número — só que para o outro lado.
|
||||
// Sem esta linha, retomar a IA deixava o badge do bucket velho até um
|
||||
// F5, o mesmo defeito do #998 por outra porta.
|
||||
qc.invalidateQueries({ queryKey: ["conversation-counts"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,14 +10,22 @@
|
||||
* o teto conhecido → cobertos pela camada semântica F4-02, não aqui. Conservador de
|
||||
* propósito: só dispara em valor claramente estruturado (evita falso-positivo tipo
|
||||
* "temos 500 clientes" — sem R$/reais não vira preço).
|
||||
*
|
||||
* Euro (`49 €`, `€ 49`, `1 497,00 €`, `49 euros`) entra pelo mesmo critério:
|
||||
* sem ele, a trava ficava cega para toda organização que cobra em euro — o
|
||||
* agente podia prometer `1 €` e nada vetava.
|
||||
*/
|
||||
|
||||
import { formatCents } from '@/lib/money';
|
||||
|
||||
export type PromiseKind = 'price' | 'discount' | 'installments';
|
||||
|
||||
export interface DetectedPromise {
|
||||
kind: PromiseKind;
|
||||
/** preço em centavos; desconto em %; parcelamento em nº de parcelas. */
|
||||
value: number;
|
||||
/** Moeda que o PRÓPRIO texto escreveu, para o veto responder nela. Ausente = real. */
|
||||
moeda?: 'EUR';
|
||||
}
|
||||
|
||||
export interface PromiseDecision {
|
||||
@@ -34,6 +42,17 @@ export interface PromiseDecision {
|
||||
const MONEY = '(\\d{1,3}(?:\\.\\d{3})+(?:,\\d{2})?|\\d+(?:,\\d{2})?)';
|
||||
const RE_PRICE_RS = new RegExp(`R\\$\\s*${MONEY}`, 'gi');
|
||||
const RE_PRICE_REAIS = new RegExp(`${MONEY}\\s*(?:reais|real)\\b`, 'gi');
|
||||
// Número monetário europeu: o milhar também vem por ESPAÇO (`1 497,00 €`, com o
|
||||
// espaço fino que o `Intl` de pt-PT/fr-FR escreve). Sem isso, o sufixo casaria
|
||||
// só `497,00` — o preço lido mil vezes menor viraria veto de um valor legítimo.
|
||||
// O lookbehind impede COMEÇAR no meio de um número (`49.90 €` não vira `90 €`)
|
||||
// e o lookahead impede PARAR no meio (`€49.90` não vira `€49`, `€1,234.56` — a
|
||||
// convenção irlandesa — não vira `€1,23`): o que o detector não lê inteiro, ele
|
||||
// não lê.
|
||||
const MONEY_EU =
|
||||
'(?<![\\d.,])(\\d{1,3}(?:[.\\u0020\\u00a0\\u202f]\\d{3})+(?:,\\d{2})?|\\d+(?:,\\d{2})?)(?![.,\\u0020\\u00a0\\u202f]?\\d)';
|
||||
const RE_PRICE_EUR_PREFIX = new RegExp(`€\\s*${MONEY_EU}`, 'gi');
|
||||
const RE_PRICE_EUR_SUFFIX = new RegExp(`${MONEY_EU}\\s*(?:€|euros?\\b|EUR\\b)`, 'gi');
|
||||
// Desconto: exige a palavra "desconto"/"off" adjacente ao percentual (conservador —
|
||||
// "100% satisfação" não é desconto).
|
||||
const RE_DISCOUNT_PREFIX = /desconto\s+(?:de\s+)?(\d{1,3})\s*(?:%|por\s*cento)/gi;
|
||||
@@ -48,9 +67,9 @@ const RE_INSTALLMENTS_PREFIX =
|
||||
const RE_INSTALLMENTS_SUFFIX =
|
||||
/(\d{1,3})\s*(?:x\b|vezes\b)\s+(?:sem\s+juros|de\s*R\$|no\s+(?:cart[ãa]o|boleto)|iguais)/gi;
|
||||
|
||||
/** "1.497,00" → 149700 centavos; "1" → 100; "500" → 50000. */
|
||||
/** "1.497,00" → 149700 centavos; "1 497,00" → 149700; "1" → 100; "500" → 50000. */
|
||||
function moneyToCents(raw: string): number {
|
||||
const normalized = raw.replace(/\./g, '').replace(',', '.');
|
||||
const normalized = raw.replace(/[.\s]/g, '').replace(',', '.');
|
||||
return Math.round(parseFloat(normalized) * 100);
|
||||
}
|
||||
|
||||
@@ -69,6 +88,9 @@ export function extractPromises(body: string): DetectedPromise[] {
|
||||
out.push({ kind: 'price', value: cents });
|
||||
for (const cents of collect(RE_PRICE_REAIS, body, (m) => moneyToCents(m[1] ?? '0')))
|
||||
out.push({ kind: 'price', value: cents });
|
||||
for (const re of [RE_PRICE_EUR_PREFIX, RE_PRICE_EUR_SUFFIX])
|
||||
for (const cents of collect(re, body, (m) => moneyToCents(m[1] ?? '0')))
|
||||
out.push({ kind: 'price', value: cents, moeda: 'EUR' });
|
||||
for (const pct of collect(RE_DISCOUNT_PREFIX, body, (m) => Number(m[1])))
|
||||
out.push({ kind: 'discount', value: pct });
|
||||
for (const pct of collect(RE_DISCOUNT_SUFFIX, body, (m) => Number(m[1])))
|
||||
@@ -84,6 +106,9 @@ export function extractPromises(body: string): DetectedPromise[] {
|
||||
|
||||
const brl = (cents: number): string =>
|
||||
`R$ ${(cents / 100).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
/** O veto responde na moeda que a mensagem escreveu: "mínimo R$ 39,00" a quem cobra em euro confunde o modelo. */
|
||||
const naMoeda = (cents: number, moeda: DetectedPromise['moeda']): string =>
|
||||
moeda === 'EUR' ? formatCents(cents, 'EUR') : brl(cents);
|
||||
|
||||
/**
|
||||
* Vete quando um valor detectado CONTRADIZ claramente a tabela versionada da org
|
||||
@@ -98,8 +123,8 @@ export function decidePromise(args: { candidate: string; table: import('./table'
|
||||
allow: false,
|
||||
code: 'promise_out_of_table',
|
||||
reason:
|
||||
`o preço ${brl(p.value)} está fora da tabela do playbook (mínimo permitido: ` +
|
||||
`${brl(table.minPriceCents)}); corrija para um valor da tabela antes de reenviar.`,
|
||||
`o preço ${naMoeda(p.value, p.moeda)} está fora da tabela do playbook (mínimo permitido: ` +
|
||||
`${naMoeda(table.minPriceCents, p.moeda)}); corrija para um valor da tabela antes de reenviar.`,
|
||||
detail: { promise_kind: 'price', detected_cents: p.value, allowed_min_cents: table.minPriceCents },
|
||||
};
|
||||
}
|
||||
|
||||
+28
-3
@@ -460,9 +460,14 @@ export const AUDIT_ACTIONS = [
|
||||
"security.mfa_exigida",
|
||||
"security.mfa_dispensada",
|
||||
"security.mfa_desativada",
|
||||
// Havia convite no signup e ele não valia (expirado, ou emitido para outro
|
||||
// e-mail). Não é falha de sistema: é a recusa deliberada de abrir organização
|
||||
// nova para quem estava tentando entrar numa existente.
|
||||
// A porta recusou o provisionamento no signup, com `motivo` no metadata. Os
|
||||
// casos: convite que não valia (expirado, ou emitido para outro e-mail),
|
||||
// `somente_convite` (a instalação não abre organização para quem chega sem
|
||||
// convite) e `acesso_revogado` (a conta teve o acesso retirado — a consulta a
|
||||
// `acessoFoiRevogado` é feita ENTRE `vinculoAtivo` e `decidirConviteDoSignup`,
|
||||
// senão o motivo auditado sairia como convite inválido, que não é a verdade
|
||||
// sobre o que aconteceu com quem foi revogado). Não é falha de sistema: é a
|
||||
// recusa deliberada de abrir organização nova.
|
||||
"auth.signup_provision_recusado",
|
||||
|
||||
// ── O teto de gasto de IA (migration 0159) ──────────────────────────────
|
||||
@@ -783,6 +788,26 @@ export const AUDIT_ACTIONS = [
|
||||
"external_db_connection.deleted",
|
||||
"external_db_connection.tested",
|
||||
"external_db_connection.read",
|
||||
|
||||
// ── Entrada com Google (issue #1388) ────────────────────────────────────
|
||||
// UM código para as recusas do OAuth, com `motivo` no metadata. Da partida
|
||||
// (`signInWithGoogle`): `provedor_indisponivel` (ninguém ligou o provedor
|
||||
// Google no projeto) e `url_ausente`. Da volta (`/auth/callback`, já depois
|
||||
// do gate): `troca_do_code_falhou` (o verificador de PKCE não voltou, o code
|
||||
// já foi gasto, o relógio do GoTrue passou) e `leitura_do_vinculo_falhou` (a
|
||||
// sessão fechou, mas a leitura do vínculo não respondeu — falha fechada).
|
||||
//
|
||||
// As duas recusas que acontecem ANTES do gate — `error` na URL e chegada sem
|
||||
// `code` — não escrevem auditoria, de propósito: quem chega assim ainda não
|
||||
// provou ser dono do verificador de PKCE, e `error` é texto cru de quem
|
||||
// chama. A doutrina é a do irmão desta rota — `app/api/v1/agenda/google/
|
||||
// callback/route.ts` audita DEPOIS do gate, nunca antes. A tela de login diz
|
||||
// o que aconteceu a quem chega por esses dois caminhos.
|
||||
//
|
||||
// A pergunta de triagem é sempre a mesma — "por que a entrada com Google não
|
||||
// fechou para esta pessoa?" —, e ela não precisa de quatro filtros no painel
|
||||
// para ser respondida; o que precisa estar separado é a causa, e ela está.
|
||||
"auth.google_signin_failed",
|
||||
] as const;
|
||||
|
||||
/** Um código de auditoria. Derivado de `AUDIT_ACTIONS` — não redigite a lista. */
|
||||
|
||||
@@ -39,8 +39,21 @@ function normalizar(email: string | null | undefined): string {
|
||||
return (email ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function decidirConviteDoSignup(user: UsuarioConfirmado): DecisaoDeSignup {
|
||||
const bruto = user.user_metadata?.["invite_token"];
|
||||
export function decidirConviteDoSignup(
|
||||
user: UsuarioConfirmado,
|
||||
/**
|
||||
* Convite vindo de FORA do `user_metadata` — hoje, o que o `/auth/callback`
|
||||
* leu da URL na volta do Google.
|
||||
*
|
||||
* ⚠️ Não é mais confiável que o `user_metadata`: os dois chegam das mãos de
|
||||
* quem está entrando. O que autoriza é o que vem DEPOIS — a assinatura HMAC
|
||||
* do token e a comparação com o e-mail que o provedor de auth confirmou —, e
|
||||
* essas duas provas valem igual para as duas origens. É por isso que este
|
||||
* parâmetro pode existir sem afrouxar nada.
|
||||
*/
|
||||
tokenDoConvite?: string | null,
|
||||
): DecisaoDeSignup {
|
||||
const bruto = tokenDoConvite?.trim() || user.user_metadata?.["invite_token"];
|
||||
// Sem convite em jogo: o caminho de sempre, intocado.
|
||||
if (typeof bruto !== "string" || bruto.trim() === "") return { tipo: "provisionar" };
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Entrada com Google: para onde o provedor devolve o navegador, e o que precisa
|
||||
* atravessar esse pulo.
|
||||
*
|
||||
* ─── A cadeia inteira, para quem for mexer depois ───────────────────────────
|
||||
*
|
||||
* 1. `/login` ou `/signup` → `signInWithGoogle` (server action) → `signInWithOAuth`
|
||||
* monta a URL do `/authorize` do GoTrue com o `code_challenge` do PKCE e o
|
||||
* verificador vai para um cookie do navegador;
|
||||
* 2. o navegador vai para o Google, consente, e o Google volta para o GoTrue;
|
||||
* 3. o GoTrue responde 302 para o `redirect_to` — esta URL, com `?code=…`;
|
||||
* 4. `/auth/callback` troca o `code` por sessão e entra no app.
|
||||
*
|
||||
* O `redirect_to` é o único canal que sobrevive ao passo 2 e 3 levando dado
|
||||
* nosso: o que o navegador tinha de nosso (cookie de sessão, e o próprio
|
||||
* verificador) está no navegador, não no Google. Por isso `next` e `convite`
|
||||
* viajam na URL — é o mesmo mecanismo que o `emailRedirectTo` do signup por
|
||||
* e-mail usa para não perder o `type` ao passar pelo GoTrue.
|
||||
*
|
||||
* ─── Por que o convite viaja ────────────────────────────────────────────────
|
||||
*
|
||||
* Sem ele, quem foi CONVIDADO e entra com Google cai no `/auth/callback` sem
|
||||
* vínculo nenhum e `ensureTenantForUser` faz o que faria com qualquer visitante:
|
||||
* abre uma organização e o torna admin dela. A pessoa termina com uma empresa
|
||||
* fantasma e um wizard que não é dela — o defeito que `lib/auth/convite-no-signup.ts`
|
||||
* existe para impedir no caminho do e-mail.
|
||||
*
|
||||
* ⚠️ O token do convite na URL NÃO é mais confiável do que o `user_metadata`: os
|
||||
* dois são escritos pelo usuário e os dois passam pelas MESMAS duas provas
|
||||
* (`verifyInviteToken` e a comparação com o e-mail que o provedor de auth
|
||||
* confirmou). Quem as aplica é `decidirConviteDoSignup`.
|
||||
*/
|
||||
import { safeNext } from "@/lib/auth/safe-next";
|
||||
|
||||
/** Onde o GoTrue devolve o navegador. Tem de estar em PUBLIC_PATHS. */
|
||||
export const CAMINHO_DO_RETORNO_DO_GOOGLE = "/auth/callback";
|
||||
|
||||
export interface DestinoDoRetornoDoGoogle {
|
||||
next?: string | null;
|
||||
convite?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* O `redirectTo` que vai no `signInWithOAuth`, absoluto e no domínio público da
|
||||
* instalação.
|
||||
*
|
||||
* Absoluto porque quem o lê é o GoTrue (e, por tabela, o Google) — os dois
|
||||
* comparam com a allowlist de redirects do projeto, e caminho relativo não
|
||||
* serve. `env.NEXT_PUBLIC_APP_URL`, e nunca `url.origin`: o header `Host` pode
|
||||
* chegar com o bind interno do container (ex.: `0.0.0.0:3000`), e o mesmo
|
||||
* motivo já está escrito em `app/auth/confirm/route.ts:69`.
|
||||
*
|
||||
* O `next` sai daqui já filtrado por `safeNext`: ele volta a nós dentro de uma
|
||||
* URL nossa, então um valor forjado não vira redirect externo nem aqui nem lá —
|
||||
* e o `/auth/callback` filtra de novo, porque a URL dele é alcançável na mão.
|
||||
*/
|
||||
export function urlDeRetornoDoGoogle(
|
||||
appUrl: string,
|
||||
destino: DestinoDoRetornoDoGoogle = {},
|
||||
): string {
|
||||
const url = new URL(CAMINHO_DO_RETORNO_DO_GOOGLE, appUrl);
|
||||
|
||||
const next = safeNext(destino.next, "/app");
|
||||
if (next !== "/app") url.searchParams.set("next", next);
|
||||
|
||||
const convite = destino.convite?.trim();
|
||||
if (convite) url.searchParams.set("convite", convite);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
+42
-8
@@ -31,6 +31,46 @@ type ProvisionOptions = {
|
||||
source?: "signup" | "recovery";
|
||||
};
|
||||
|
||||
/**
|
||||
* De qual organização esta pessoa é, se é de alguma — ou `null`.
|
||||
*
|
||||
* Existe separado de `ensureTenantForUser` porque há um caller que precisa
|
||||
* PERGUNTAR sem AGIR: a volta da entrada com Google (`app/auth/callback/route.ts`)
|
||||
* chega sem saber se aquilo é um primeiro acesso ou alguém voltando, e a
|
||||
* resposta muda tudo o que vem depois — travas de cadastro, convite,
|
||||
* provisionamento. Chamar `ensureTenantForUser` para descobrir seria agir antes
|
||||
* de decidir: quem entrasse sem convite numa instalação `so_convite` já teria
|
||||
* ganhado empresa antes de a política ser lida.
|
||||
*
|
||||
* Service role, como o resto do provisionamento: quem ainda não pertence a
|
||||
* organização nenhuma não enxerga `user_organizations` por RLS. O `user_id`
|
||||
* vem sempre do JWT já validado, nunca do corpo de uma requisição.
|
||||
*/
|
||||
export async function vinculoAtivo(userId: string): Promise<string | null> {
|
||||
const admin = createAdminClient();
|
||||
|
||||
const { data, error } = await admin
|
||||
.from("user_organizations")
|
||||
.select("organization_id")
|
||||
.eq("user_id", userId)
|
||||
.is("revoked_at", null)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
// Erro NÃO vira `null`, pela mesma razão escrita em `vinculoVivo`, ~300 linhas
|
||||
// abaixo: `null` já quer dizer "não pertence a organização nenhuma", e uma
|
||||
// leitura que falhou não é a mesma coisa que um vínculo que não existe. Lido
|
||||
// como equivalente, o soluço de leitura expulsa um membro de casa numa
|
||||
// instalação `so_convite` e, numa instalação aberta, entrega organização nova
|
||||
// a quem já tinha uma. Quem decide o que fazer com a falha é quem chama
|
||||
// (`app/auth/callback/route.ts` degrada para falha FECHADA); aqui ela sobe alto.
|
||||
if (error) {
|
||||
throw new Error(`provisioning: leitura do vínculo ativo falhou: ${error.message}`);
|
||||
}
|
||||
|
||||
return data?.organization_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provisiona o tenant de um usuário recém-confirmado via signup self-service:
|
||||
* cria a organização (status `active`, `onboarded_at` null → cai no onboarding)
|
||||
@@ -49,14 +89,8 @@ export async function ensureTenantForUser(
|
||||
): Promise<{ provisioned: boolean; organizationId?: string }> {
|
||||
const admin = createAdminClient();
|
||||
|
||||
const { data: existing } = await admin
|
||||
.from("user_organizations")
|
||||
.select("organization_id")
|
||||
.eq("user_id", user.id)
|
||||
.is("revoked_at", null)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (existing) return { provisioned: false, organizationId: existing.organization_id };
|
||||
const organizationId = await vinculoAtivo(user.id);
|
||||
if (organizationId) return { provisioned: false, organizationId };
|
||||
|
||||
const orgName =
|
||||
(user.user_metadata?.org_name as string | undefined)?.trim() ||
|
||||
|
||||
@@ -7,6 +7,20 @@ export const PUBLIC_PATHS: RegExp[] = [
|
||||
/^\/login(\/.*)?$/,
|
||||
/^\/signup$/,
|
||||
/^\/auth\/confirm$/,
|
||||
// A VOLTA DA ENTRADA COM GOOGLE (issue #1388). Quem chega aqui é o NAVEGADOR
|
||||
// que o Google devolveu, via 302 do GoTrue — navegação vinda de outro site,
|
||||
// onde o cookie de sessão (`sameSite: "strict"`) não viaja por definição.
|
||||
// Sem esta linha o `proxy` responde 307 para `/login` antes de a rota
|
||||
// existir, e o fluxo NUNCA completa: mesma classe medida na v1.8.0, em
|
||||
// produção, com o callback da agenda (`GET /api/v1/agenda/google/callback`
|
||||
// → 401 `unauthenticated`).
|
||||
//
|
||||
// A identidade NÃO vem da sessão: vem do `code` que o GoTrue assinou, trocado
|
||||
// por sessão DENTRO da rota (`exchangeCodeForSession`), que só fecha se o
|
||||
// verificador de PKCE gravado na ida voltar — em cookie `Lax`, ver
|
||||
// `createClientDeEntradaComGoogle`. Âncora `$` de propósito: nenhum sub-path
|
||||
// futuro nasce público de carona.
|
||||
/^\/auth\/callback$/,
|
||||
/^\/403$/,
|
||||
/^\/admin\/forbidden$/,
|
||||
/^\/404$/,
|
||||
|
||||
@@ -169,3 +169,118 @@ export async function registrarFalhaDeLogin(email: string, limits: AuthRateLimit
|
||||
if (limits.id === undefined) return;
|
||||
await checkRateLimit(`auth:login_fail:id:${opaque(email)}`, limits.id, limits.windowSec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bloqueio por falha de TOKEN DE API (`dsk_...`) — o MCP não tinha nenhum
|
||||
* (issue #1447).
|
||||
*
|
||||
* O login já contava falhas; o token de máquina, não. `validateBearerToken`
|
||||
* recusava e seguia, e cada recusa custava um lookup em `api_tokens` — então a
|
||||
* mesma origem podia varrer tokens para sempre a custo zero. Dois baldes,
|
||||
* porque são dois ataques diferentes:
|
||||
*
|
||||
* - por ORIGEM (`api_token_fail:ip`): quem ADIVINHA — cabeçalho ausente ou
|
||||
* torto, `dsk_` malformado, hash desconhecido. É o freio de quem varre de
|
||||
* um lugar só.
|
||||
* - pelo VALOR APRESENTADO (`api_token_fail:token`, hash SHA256 do que veio
|
||||
* no header): quem REPETE o mesmo chute, ou o mesmo token já morto
|
||||
* (revogado, expirado), trocando de IP a cada tentativa — o espelho do
|
||||
* caso acima, que o balde por IP vê como "1 falha por IP" e não barra.
|
||||
*
|
||||
* `revoked`/`expired` NÃO debitam o balde por origem: quem apresenta um token
|
||||
* que existiu não está adivinhando, e transformar cliente desatualizado em
|
||||
* bloqueio por IP puniria NAT corporativo — exatamente o que `authRateLimited`
|
||||
* evita. Eles debitam só o balde do valor apresentado, que não afeta mais
|
||||
* ninguém.
|
||||
*
|
||||
* `lookup_failed` não conta em balde nenhum: a falha é nossa (banco fora), e
|
||||
* indisponibilidade de infraestrutura não pode virar bloqueio de cliente.
|
||||
*
|
||||
* Sem Redis de pé a contagem cai para a memória do processo — vira teto por
|
||||
* instância, o mesmo aviso que o resto deste módulo já carrega. As funções
|
||||
* aqui falham ABERTO: erro ao consultar o teto não pode virar 500 no meio da
|
||||
* autenticação — o que ele protege é custo, não acesso.
|
||||
*/
|
||||
export interface TokenFailureLimits {
|
||||
/** Teto por origem, por janela. */
|
||||
ip: number;
|
||||
/** Teto por valor apresentado, por janela. */
|
||||
token: number;
|
||||
windowSec: number;
|
||||
}
|
||||
|
||||
export const TOKEN_FAILURE_LIMITS: TokenFailureLimits = { ip: 30, token: 5, windowSec: 300 };
|
||||
|
||||
export interface FalhaDeTokenOpcoes {
|
||||
/** `true` (default) = também debita o balde por origem: quem ADIVINHOU. */
|
||||
contaNoIp?: boolean;
|
||||
}
|
||||
|
||||
function tokenFailureIpKey(ip: string): string {
|
||||
return `api_token_fail:ip:${opaque(ip)}`;
|
||||
}
|
||||
|
||||
function tokenFailureValueKey(plaintext: string): string {
|
||||
return `api_token_fail:token:${opaque(plaintext)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` = teto estourado, barre ANTES de resolver o token.
|
||||
*
|
||||
* Consulta sem incrementar: quem incrementa é `registrarFalhaDeToken`, e só
|
||||
* quando a tentativa realmente falhou. Acerto não paga imposto.
|
||||
*/
|
||||
export async function tokenFailureLimited(
|
||||
plaintext: string | null,
|
||||
limits: TokenFailureLimits = TOKEN_FAILURE_LIMITS,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const ip = await clientIp();
|
||||
if (ip !== null && (await peekRateLimit(tokenFailureIpKey(ip), limits.windowSec)) >= limits.ip) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
plaintext !== null &&
|
||||
(await peekRateLimit(tokenFailureValueKey(plaintext), limits.windowSec)) >= limits.token
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[auth.rate-limit] teto de token indisponível (falha aberta)",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra a tentativa que FALHOU.
|
||||
*
|
||||
* @param plaintext valor apresentado; `null` quando não veio token nenhum
|
||||
* (cabeçalho ausente/malformado) — aí só o balde por origem existe.
|
||||
*/
|
||||
export async function registrarFalhaDeToken(
|
||||
plaintext: string | null,
|
||||
opcoes: FalhaDeTokenOpcoes = {},
|
||||
limits: TokenFailureLimits = TOKEN_FAILURE_LIMITS,
|
||||
): Promise<void> {
|
||||
const contaNoIp = opcoes.contaNoIp ?? true;
|
||||
try {
|
||||
if (contaNoIp) {
|
||||
const ip = await clientIp();
|
||||
if (ip !== null) {
|
||||
await checkRateLimit(tokenFailureIpKey(ip), limits.ip, limits.windowSec);
|
||||
}
|
||||
}
|
||||
if (plaintext !== null) {
|
||||
await checkRateLimit(tokenFailureValueKey(plaintext), limits.token, limits.windowSec);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[auth.rate-limit] falha ao registrar tentativa de token (falha aberta)",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* `vinculoAtivo`: falha de leitura NÃO vira o mesmo `null` de "não pertence a
|
||||
* organização nenhuma".
|
||||
*
|
||||
* A volta da entrada com Google bifurca entrada × cadastro por este `null`. Se
|
||||
* um erro de leitura chegasse como `null`, o resto da rota trataria quem já é de
|
||||
* casa como primeiro acesso — e numa instalação aberta entregaria organização
|
||||
* nova (com `role: "admin"`) a quem já tinha uma. O contraste está no próprio
|
||||
* módulo: `vinculoVivo` faz `if (error) throw` pela mesma razão.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
linha: null as { organization_id: string } | null,
|
||||
erro: null as { message: string } | null,
|
||||
filtros: [] as unknown[][],
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/supabase/admin", () => ({
|
||||
createAdminClient: () => ({
|
||||
from: () => {
|
||||
const chain = {
|
||||
select: () => chain,
|
||||
eq: (...args: unknown[]) => {
|
||||
h.filtros.push(args);
|
||||
return chain;
|
||||
},
|
||||
is: (...args: unknown[]) => {
|
||||
h.filtros.push(args);
|
||||
return chain;
|
||||
},
|
||||
limit: () => chain,
|
||||
maybeSingle: async () => ({ data: h.linha, error: h.erro }),
|
||||
};
|
||||
return chain;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { vinculoAtivo } from "./provision";
|
||||
|
||||
describe("vinculoAtivo", () => {
|
||||
beforeEach(() => {
|
||||
h.linha = null;
|
||||
h.erro = null;
|
||||
h.filtros = [];
|
||||
});
|
||||
|
||||
it('falha de leitura LANÇA — não devolve o `null` de "não há vínculo"', async () => {
|
||||
h.erro = { message: "conexão caiu" };
|
||||
|
||||
await expect(vinculoAtivo("u1")).rejects.toThrow(/vínculo ativo/);
|
||||
});
|
||||
|
||||
it("conta sem vínculo continua devolvendo `null` — o caso legítimo não muda", async () => {
|
||||
await expect(vinculoAtivo("u1")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("vínculo vivo devolve o id da organização, e a leitura filtra revogado", async () => {
|
||||
h.linha = { organization_id: "org-1" };
|
||||
|
||||
await expect(vinculoAtivo("u1")).resolves.toBe("org-1");
|
||||
expect(h.filtros).toEqual([
|
||||
["user_id", "u1"],
|
||||
["revoked_at", null],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -121,3 +121,115 @@ describe("lerPlanilha — a recusa nomeia a coluna que falta", () => {
|
||||
expect(erro).not.toContain("de nombre");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Um apelido por linha, os de antes e os de espanhol — e não uma amostra: o
|
||||
* mapa de colunas para no primeiro campo que contém o cabeçalho, então um
|
||||
* apelido repetido em dois campos cairia em silêncio no primeiro, e só uma
|
||||
* linha por apelido denuncia isso. O efeito no produto prova em QUAL campo, e
|
||||
* `colunasIgnoradas` vazio prova que o cabeçalho foi reconhecido.
|
||||
*/
|
||||
describe("lerPlanilha — todo apelido de coluna cai no seu campo", () => {
|
||||
type Produto = Extract<ReturnType<typeof lerPlanilha>, { produtos: unknown }>["produtos"][number];
|
||||
|
||||
// Separador `;` sempre: os valores levam vírgula ("1.200,00") e não precisam de aspas.
|
||||
const CAMPOS: Record<string, { ancora: string; celula: string; le: (p: Produto) => unknown; esperado: unknown }> = {
|
||||
codigo: { ancora: "nome;preco", celula: "ABC-1", le: (p) => p.codigo, esperado: "ABC-1" },
|
||||
nome: { ancora: "preco", celula: "Café", le: (p) => p.nome, esperado: "Café" },
|
||||
preco: { ancora: "nome", celula: "1.200,00", le: (p) => p.preco_cents, esperado: 120000 },
|
||||
custo: { ancora: "nome;preco", celula: "8,50", le: (p) => p.custo_cents, esperado: 850 },
|
||||
marca: { ancora: "nome;preco", celula: "Melitta", le: (p) => p.marca, esperado: "Melitta" },
|
||||
categoria: { ancora: "nome;preco", celula: "Bebidas", le: (p) => p.categoria, esperado: "Bebidas" },
|
||||
quantidade: {
|
||||
ancora: "nome;preco",
|
||||
celula: "7",
|
||||
le: (p) => [p.quantidade, p.controla_estoque],
|
||||
esperado: [7, true],
|
||||
},
|
||||
};
|
||||
// Célula das colunas âncora, na ordem em que aparecem em `ancora`.
|
||||
const CELULA_ANCORA: Record<string, string> = { nome: "Café", preco: "10" };
|
||||
|
||||
const APELIDOS: ReadonlyArray<readonly [campo: string, apelido: string]> = [
|
||||
// pt-BR / en (o que já existia)
|
||||
...["codigo", "código", "sku", "ref", "referencia", "referência", "cod"].map((a) => ["codigo", a] as const),
|
||||
...["nome", "produto", "descricao", "descrição", "titulo", "título", "item"].map((a) => ["nome", a] as const),
|
||||
...["preco", "preço", "valor", "preco de venda", "preço de venda", "venda"].map((a) => ["preco", a] as const),
|
||||
...["custo", "preco de custo", "preço de custo", "compra"].map((a) => ["custo", a] as const),
|
||||
...["marca", "fabricante"].map((a) => ["marca", a] as const),
|
||||
...["categoria", "tipo", "departamento"].map((a) => ["categoria", a] as const),
|
||||
...["quantidade", "estoque", "qtd", "qtde", "qty"].map((a) => ["quantidade", a] as const),
|
||||
// es — como o Excel escreve, com acento e caixa. Os quatro primeiros grupos
|
||||
// já valiam por coincidirem com o português (Código, Categoría, Marca, Compra…).
|
||||
...["Código", "Referencia"].map((a) => ["codigo", a] as const),
|
||||
...["Categoría", "Departamento"].map((a) => ["categoria", a] as const),
|
||||
...["Marca", "Fabricante"].map((a) => ["marca", a] as const),
|
||||
...["Compra"].map((a) => ["custo", a] as const),
|
||||
// …e os que só o espanhol escreve:
|
||||
...["Nombre", "Producto", "Descripción", "Artículo"].map((a) => ["nome", a] as const),
|
||||
...["Precio", "Precio de venta", "Venta"].map((a) => ["preco", a] as const),
|
||||
...["Costo", "Coste", "Precio de costo", "Precio de coste"].map((a) => ["custo", a] as const),
|
||||
...["Cantidad", "Existencias", "Stock"].map((a) => ["quantidade", a] as const),
|
||||
];
|
||||
|
||||
it.each(APELIDOS)("%s ← %s", (campo, apelido) => {
|
||||
const { ancora, celula, le, esperado } = CAMPOS[campo]!;
|
||||
const cabecalho = `${apelido};${ancora}`;
|
||||
const linha = `${celula};${ancora.split(";").map((c) => CELULA_ANCORA[c]).join(";")}`;
|
||||
const resultado = lerPlanilha(`${cabecalho}\n${linha}\n`);
|
||||
if ("erro" in resultado) throw new Error(`planilha recusada: ${resultado.erro}`);
|
||||
expect(resultado.colunasIgnoradas).toEqual([]);
|
||||
expect(le(resultado.produtos[0]!)).toEqual(esperado);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lerPlanilha — planilha em espanhol", () => {
|
||||
it("cabeçalho completo como o Excel escreve", () => {
|
||||
const csv = [
|
||||
"Código;Producto;Precio;Costo;Marca;Categoría;Cantidad",
|
||||
"CAF-1;Café molido;1.200,50;800,00;Melitta;Bebidas;12",
|
||||
].join("\n");
|
||||
const resultado = lerPlanilha(csv);
|
||||
if ("erro" in resultado) throw new Error(`planilha recusada: ${resultado.erro}`);
|
||||
expect(resultado.erros).toEqual([]);
|
||||
expect(resultado.colunasIgnoradas).toEqual([]);
|
||||
expect(resultado.produtos).toEqual([
|
||||
{
|
||||
linha: 2,
|
||||
codigo: "CAF-1",
|
||||
nome: "Café molido",
|
||||
preco_cents: 120050,
|
||||
custo_cents: 80000,
|
||||
marca: "Melitta",
|
||||
categoria: "Bebidas",
|
||||
quantidade: 12,
|
||||
controla_estoque: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("Descripción nomeia o produto (como `descrição` em português) e Precio de venda é o preço", () => {
|
||||
const resultado = lerPlanilha("Descripción;Precio de venta;Existencias\nTaza de barro;15,00;3\n");
|
||||
if ("erro" in resultado) throw new Error(`planilha recusada: ${resultado.erro}`);
|
||||
expect(resultado.colunasIgnoradas).toEqual([]);
|
||||
expect(resultado.produtos[0]).toMatchObject({
|
||||
nome: "Taza de barro",
|
||||
preco_cents: 1500,
|
||||
quantidade: 3,
|
||||
controla_estoque: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("cabeçalho misto pt + es", () => {
|
||||
const resultado = lerPlanilha("Nome;Precio;Estoque\nLivro;30,00;4\n");
|
||||
if ("erro" in resultado) throw new Error(`planilha recusada: ${resultado.erro}`);
|
||||
expect(resultado.colunasIgnoradas).toEqual([]);
|
||||
expect(resultado.produtos[0]).toMatchObject({ nome: "Livro", preco_cents: 3000, quantidade: 4 });
|
||||
});
|
||||
|
||||
it("sem coluna de nome nem de preço em espanhol, a recusa nomeia as duas e lista o que achou", () => {
|
||||
expect(recusa("Marca;Categoría\nMelitta;Café\n")).toBe(
|
||||
"A planilha precisa de uma coluna de nome e de preço. Encontrei: Marca, Categoría.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,15 +20,20 @@ import { precoParaCentavos } from "@/lib/schemas/produtos";
|
||||
* errado dito a um cliente depois.
|
||||
*/
|
||||
|
||||
/** Como cada coluna pode vir escrita. A primeira forma é a que a gente sugere. */
|
||||
/**
|
||||
* Como cada coluna pode vir escrita. A primeira forma é a que a gente sugere.
|
||||
* As de espanhol (`nombre`, `precio`, `costo`, `cantidad`…) cumprem o que a tela
|
||||
* promete a quem a usa nesse idioma; a comparação tira acento e caixa dos dois
|
||||
* lados, então as formas novas entram sem acento.
|
||||
*/
|
||||
const COLUNAS: Record<string, readonly string[]> = {
|
||||
codigo: ["codigo", "código", "sku", "ref", "referencia", "referência", "cod"],
|
||||
nome: ["nome", "produto", "descricao", "descrição", "titulo", "título", "item"],
|
||||
preco: ["preco", "preço", "valor", "preco de venda", "preço de venda", "venda"],
|
||||
custo: ["custo", "preco de custo", "preço de custo", "compra"],
|
||||
nome: ["nome", "produto", "descricao", "descrição", "titulo", "título", "item", "nombre", "producto", "descripcion", "articulo"],
|
||||
preco: ["preco", "preço", "valor", "preco de venda", "preço de venda", "venda", "precio", "precio de venta", "venta"],
|
||||
custo: ["custo", "preco de custo", "preço de custo", "compra", "costo", "coste", "precio de costo", "precio de coste"],
|
||||
marca: ["marca", "fabricante"],
|
||||
categoria: ["categoria", "tipo", "departamento"],
|
||||
quantidade: ["quantidade", "estoque", "qtd", "qtde", "qty"],
|
||||
quantidade: ["quantidade", "estoque", "qtd", "qtde", "qty", "cantidad", "existencias", "stock"],
|
||||
};
|
||||
|
||||
function normalizarCabecalho(texto: string): string {
|
||||
|
||||
@@ -82,6 +82,132 @@ describe("mapHeader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Cobre TODO apelido, e não uma amostra: um apelido repetido em dois campos cai
|
||||
* em silêncio no primeiro (`mapHeader` para no primeiro que contém a célula),
|
||||
* e só uma linha por apelido denuncia isso. Os de espanhol são a promessa da
|
||||
* frase "columnas reconocidas: nombre, teléfono, email…" do diálogo de importação.
|
||||
*/
|
||||
describe("mapHeader — todo apelido reconhecido cai no seu campo", () => {
|
||||
it.each([
|
||||
// pt-BR / en (o que já existia)
|
||||
["name", "name"],
|
||||
["nome", "name"],
|
||||
["cliente", "name"],
|
||||
["display_name", "display_name"],
|
||||
["apelido", "display_name"],
|
||||
["nome_de_exibicao", "display_name"],
|
||||
["email", "email"],
|
||||
["e_mail", "email"],
|
||||
["phone_number", "phone_number"],
|
||||
["telefone", "phone_number"],
|
||||
["whatsapp", "phone_number"],
|
||||
["celular", "phone_number"],
|
||||
["fone", "phone_number"],
|
||||
["cpf", "cpf"],
|
||||
["birthdate", "birthdate"],
|
||||
["nascimento", "birthdate"],
|
||||
["data_de_nascimento", "birthdate"],
|
||||
["aniversario", "birthdate"],
|
||||
["tags", "tags"],
|
||||
["etiquetas", "tags"],
|
||||
["grupos", "tags"],
|
||||
// es
|
||||
["nombre", "name"],
|
||||
["apodo", "display_name"],
|
||||
["nombre_para_mostrar", "display_name"],
|
||||
["correo", "email"],
|
||||
["correo_electronico", "email"],
|
||||
["telefono", "phone_number"],
|
||||
["movil", "phone_number"],
|
||||
["nacimiento", "birthdate"],
|
||||
["fecha_de_nacimiento", "birthdate"],
|
||||
["cumpleanos", "birthdate"],
|
||||
])("%s → %s", (alias, campo) => {
|
||||
expect(mapHeader([alias]).indices).toEqual({ [campo]: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapHeader — cabeçalho em espanhol como o Excel escreve", () => {
|
||||
it("acento, caixa e espaços: Nombre, Teléfono, Correo electrónico…", () => {
|
||||
const { indices, motivo } = mapHeader([
|
||||
"Nombre",
|
||||
"Nombre para mostrar",
|
||||
"Correo electrónico",
|
||||
"Teléfono",
|
||||
"Fecha de nacimiento",
|
||||
"Etiquetas",
|
||||
]);
|
||||
expect(motivo).toBeNull();
|
||||
expect(indices).toEqual({
|
||||
name: 0,
|
||||
display_name: 1,
|
||||
email: 2,
|
||||
phone_number: 3,
|
||||
birthdate: 4,
|
||||
tags: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("Móvil e Cumpleaños (ñ e acento saem na normalização)", () => {
|
||||
const { indices, motivo } = mapHeader(["Móvil", "Cumpleaños"]);
|
||||
expect(motivo).toBeNull();
|
||||
expect(indices).toEqual({ phone_number: 0, birthdate: 1 });
|
||||
});
|
||||
|
||||
it("cabeçalho misto pt + es mapeia cada coluna", () => {
|
||||
const { indices, motivo } = mapHeader(["Nome", "Teléfono", "Correo", "Nascimento"]);
|
||||
expect(motivo).toBeNull();
|
||||
expect(indices).toEqual({ name: 0, phone_number: 1, email: 2, birthdate: 3 });
|
||||
});
|
||||
|
||||
it("em espanhol, sem telefone nem correo, falha aberto com o mesmo motivo", () => {
|
||||
const { motivo } = mapHeader(["Nombre", "Apodo", "Nacimiento"]);
|
||||
expect(motivo).toMatch(/sem coluna de telefone nem e-mail/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSV em espanhol de ponta a ponta (parseCsv → mapHeader → mapLinha)", () => {
|
||||
function importa(csv: string) {
|
||||
const [cabecalho, linha] = parseCsv(csv);
|
||||
const { indices, motivo: motivoHeader } = mapHeader(cabecalho!);
|
||||
expect(motivoHeader).toBeNull();
|
||||
return mapLinha(linha!, indices);
|
||||
}
|
||||
|
||||
it("separado por ponto e vírgula (etiquetas entre aspas, que têm ';' dentro)", () => {
|
||||
const { contato, motivo } = importa(
|
||||
[
|
||||
"nombre;teléfono;correo electrónico;nacimiento;etiquetas",
|
||||
'Ana García;+34 612 345 678;ana@ejemplo.com;15/03/1990;"vip; newsletter"',
|
||||
].join("\n"),
|
||||
);
|
||||
expect(motivo).toBeNull();
|
||||
expect(contato.name).toBe("Ana García");
|
||||
expect(contato.phone_number).toBe("+34612345678");
|
||||
expect(contato.email).toBe("ana@ejemplo.com");
|
||||
expect(contato.birthdate).toBe("1990-03-15");
|
||||
expect(contato.tags).toEqual(["vip", "newsletter"]);
|
||||
});
|
||||
|
||||
it("separado por vírgula, com apodo e móvil", () => {
|
||||
const { contato, motivo } = importa(
|
||||
[
|
||||
"Nombre,Apodo,Móvil,Correo,Fecha de nacimiento,Etiquetas",
|
||||
// Etiquetas separam por ';' ou '|' (a vírgula é o delimitador do arquivo).
|
||||
"Luis Pérez,Lucho,+34 699 111 222,luis@ejemplo.com,02/11/1985,vip; newsletter",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(motivo).toBeNull();
|
||||
expect(contato.name).toBe("Luis Pérez");
|
||||
expect(contato.display_name).toBe("Lucho");
|
||||
expect(contato.phone_number).toBe("+34699111222");
|
||||
expect(contato.email).toBe("luis@ejemplo.com");
|
||||
expect(contato.birthdate).toBe("1985-11-02");
|
||||
expect(contato.tags).toEqual(["vip", "newsletter"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizaTelefone", () => {
|
||||
it.each([
|
||||
["+5511999998888", "+5511999998888"],
|
||||
|
||||
+18
-8
@@ -241,17 +241,27 @@ function detectDelimiter(text: string): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Aceita apelidos pt-BR/en porque a planilha é feita por humano: quem importa
|
||||
* tem "Telefone" no Excel, não "phone_number". Acento/caixa/separador são
|
||||
* normalizados ("Data de Nascimento" → data_de_nascimento).
|
||||
* Aceita apelidos pt-BR/en/es porque a planilha é feita por humano: quem importa
|
||||
* tem "Telefone" (ou "Teléfono") no Excel, não "phone_number". Acento/caixa/
|
||||
* separador são normalizados ("Data de Nascimento" → data_de_nascimento,
|
||||
* "Fecha de nacimiento" → fecha_de_nacimiento). Os de espanhol cumprem o que a
|
||||
* tela de importação promete a quem a usa nesse idioma.
|
||||
*/
|
||||
const HEADER_ALIASES: Record<string, readonly string[]> = {
|
||||
name: ["name", "nome", "cliente"],
|
||||
display_name: ["display_name", "apelido", "nome_de_exibicao"],
|
||||
email: ["email", "e_mail"],
|
||||
phone_number: ["phone_number", "telefone", "whatsapp", "celular", "fone"],
|
||||
name: ["name", "nome", "cliente", "nombre"],
|
||||
display_name: ["display_name", "apelido", "nome_de_exibicao", "apodo", "nombre_para_mostrar"],
|
||||
email: ["email", "e_mail", "correo", "correo_electronico"],
|
||||
phone_number: ["phone_number", "telefone", "whatsapp", "celular", "fone", "telefono", "movil"],
|
||||
cpf: ["cpf"],
|
||||
birthdate: ["birthdate", "nascimento", "data_de_nascimento", "aniversario"],
|
||||
birthdate: [
|
||||
"birthdate",
|
||||
"nascimento",
|
||||
"data_de_nascimento",
|
||||
"aniversario",
|
||||
"nacimiento",
|
||||
"fecha_de_nacimiento",
|
||||
"cumpleanos",
|
||||
],
|
||||
tags: ["tags", "etiquetas", "grupos"],
|
||||
};
|
||||
|
||||
|
||||
+1672
-1630
File diff suppressed because it is too large
Load Diff
@@ -69,3 +69,126 @@ describe("lerPlanilhaDeLeads — mensagens de erro passam por t()", () => {
|
||||
expect(resultado.erros[0]!.motivo).toBe('valor não reconhecido ("abc") — escreva assim: 1.200,00');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Um apelido por linha, os de antes e os de espanhol — e não uma amostra: o
|
||||
* mapa de colunas para no primeiro campo que contém o cabeçalho, então um
|
||||
* apelido repetido em dois campos cairia em silêncio no primeiro, e só uma
|
||||
* linha por apelido denuncia isso. `colunasIgnoradas` vazio é o que prova que o
|
||||
* cabeçalho foi reconhecido; o efeito no lead prova em QUAL campo.
|
||||
*/
|
||||
describe("lerPlanilhaDeLeads — todo apelido de coluna cai no seu campo", () => {
|
||||
type Lead = Extract<ReturnType<typeof lerPlanilhaDeLeads>, { leads: unknown }>["leads"][number];
|
||||
|
||||
// célula que o campo entende + o que sai dela no lead
|
||||
const CAMPOS: Record<string, { celula: string; le: (l: Lead) => unknown; esperado: unknown }> = {
|
||||
titulo: { celula: "Reforma", le: (l) => [l.title, l.nome_do_contato], esperado: ["Reforma", null] },
|
||||
contato: { celula: "Ana", le: (l) => l.nome_do_contato, esperado: "Ana" },
|
||||
telefone: { celula: "11999998888", le: (l) => l.telefone, esperado: "+5511999998888" },
|
||||
email: { celula: "ana@exemplo.com", le: (l) => l.email, esperado: "ana@exemplo.com" },
|
||||
descricao: { celula: "texto livre", le: (l) => l.description, esperado: "texto livre" },
|
||||
valor: { celula: "1.200,00", le: (l) => l.value_cents, esperado: 120000 },
|
||||
origem: { celula: "feira", le: (l) => l.source, esperado: "feira" },
|
||||
etiquetas: { celula: "vip;novo", le: (l) => l.tags, esperado: ["vip", "novo"] },
|
||||
};
|
||||
|
||||
const APELIDOS: ReadonlyArray<readonly [campo: string, apelido: string]> = [
|
||||
// pt-BR / en (o que já existia)
|
||||
...["nome", "titulo", "título", "lead", "negocio", "negócio", "oportunidade", "empresa", "assunto"].map(
|
||||
(a) => ["titulo", a] as const,
|
||||
),
|
||||
...["nome do contato", "contato", "responsavel", "responsável", "pessoa"].map((a) => ["contato", a] as const),
|
||||
...["telefone", "celular", "whatsapp", "fone", "phone"].map((a) => ["telefone", a] as const),
|
||||
...["email", "e-mail"].map((a) => ["email", a] as const),
|
||||
...["descricao", "descrição", "observacao", "observação", "observacoes", "observações", "notas", "detalhes"].map(
|
||||
(a) => ["descricao", a] as const,
|
||||
),
|
||||
...["valor", "preco", "preço", "ticket", "value"].map((a) => ["valor", a] as const),
|
||||
...["origem", "fonte", "canal", "source"].map((a) => ["origem", a] as const),
|
||||
...["tags", "etiquetas", "marcadores"].map((a) => ["etiquetas", a] as const),
|
||||
// es — como o Excel escreve, com acento e caixa
|
||||
...["Nombre", "Oportunidad", "Asunto"].map((a) => ["titulo", a] as const),
|
||||
...["Nombre del contacto", "Contacto", "Responsable", "Persona"].map((a) => ["contato", a] as const),
|
||||
...["Teléfono", "Móvil"].map((a) => ["telefone", a] as const),
|
||||
...["Correo", "Correo electrónico"].map((a) => ["email", a] as const),
|
||||
...["Descripción", "Observación", "Observaciones", "Detalles"].map((a) => ["descricao", a] as const),
|
||||
...["Precio", "Importe", "Monto"].map((a) => ["valor", a] as const),
|
||||
...["Origen", "Fuente"].map((a) => ["origem", a] as const),
|
||||
];
|
||||
|
||||
it.each(APELIDOS)("%s ← %s", (campo, apelido) => {
|
||||
const { celula, le, esperado } = CAMPOS[campo]!;
|
||||
// Sem âncora nas colunas de nome, o campo testado é a única coluna; nos
|
||||
// demais, uma coluna `titulo` nomeia o card e o apelido vai ao lado.
|
||||
const nomeia = campo === "titulo" || campo === "contato";
|
||||
const csv = nomeia ? `${apelido}\n${celula}\n` : `titulo,${apelido}\nNegócio,${celula}\n`;
|
||||
const resultado = lerPlanilhaDeLeads(csv);
|
||||
if ("erro" in resultado) throw new Error(`planilha recusada: ${resultado.erro}`);
|
||||
expect(resultado.colunasIgnoradas).toEqual([]);
|
||||
expect(le(resultado.leads[0]!)).toEqual(esperado);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lerPlanilhaDeLeads — planilha em espanhol", () => {
|
||||
it("cabeçalho completo como o Excel escreve", () => {
|
||||
const csv = [
|
||||
"Nombre;Nombre del contacto;Teléfono;Correo electrónico;Descripción;Precio;Origen;Etiquetas",
|
||||
"Reforma de cocina;Ana García;+34 612 345 678;ana@ejemplo.com;Pidió presupuesto;1.200,50;Feria;vip, nuevo",
|
||||
].join("\n");
|
||||
const resultado = lerPlanilhaDeLeads(csv);
|
||||
if ("erro" in resultado) throw new Error(`planilha recusada: ${resultado.erro}`);
|
||||
expect(resultado.erros).toEqual([]);
|
||||
expect(resultado.colunasIgnoradas).toEqual([]);
|
||||
expect(resultado.leads).toEqual([
|
||||
{
|
||||
linha: 2,
|
||||
title: "Reforma de cocina",
|
||||
description: "Pidió presupuesto",
|
||||
value_cents: 120050,
|
||||
telefone: "+34612345678",
|
||||
nome_do_contato: "Ana García",
|
||||
email: "ana@ejemplo.com",
|
||||
tags: ["vip", "nuevo"],
|
||||
source: "Feria",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("separada por vírgula, com móvil e observaciones", () => {
|
||||
const csv = [
|
||||
"Nombre,Contacto,Móvil,Correo,Observaciones",
|
||||
"Curso de inglés,Luis Pérez,+34 699 111 222,luis@ejemplo.com,Llamar el lunes",
|
||||
].join("\n");
|
||||
const resultado = lerPlanilhaDeLeads(csv);
|
||||
if ("erro" in resultado) throw new Error(`planilha recusada: ${resultado.erro}`);
|
||||
expect(resultado.colunasIgnoradas).toEqual([]);
|
||||
expect(resultado.leads[0]).toMatchObject({
|
||||
title: "Curso de inglés",
|
||||
nome_do_contato: "Luis Pérez",
|
||||
telefone: "+34699111222",
|
||||
email: "luis@ejemplo.com",
|
||||
description: "Llamar el lunes",
|
||||
});
|
||||
});
|
||||
|
||||
it("cabeçalho misto pt + es", () => {
|
||||
const resultado = lerPlanilhaDeLeads("Nome,Teléfono,Correo\nLoja,11999998888,a@b.co\n");
|
||||
if ("erro" in resultado) throw new Error(`planilha recusada: ${resultado.erro}`);
|
||||
expect(resultado.colunasIgnoradas).toEqual([]);
|
||||
expect(resultado.leads[0]).toMatchObject({ title: "Loja", telefone: "+5511999998888", email: "a@b.co" });
|
||||
});
|
||||
|
||||
it("dois cabeçalhos do mesmo campo: o primeiro vale, o segundo é dito como ignorado", () => {
|
||||
const resultado = lerPlanilhaDeLeads("Nombre,Asunto\nUno,Dos\n");
|
||||
if ("erro" in resultado) throw new Error(`planilha recusada: ${resultado.erro}`);
|
||||
expect(resultado.leads[0]!.title).toBe("Uno");
|
||||
expect(resultado.colunasIgnoradas).toEqual(["Asunto"]);
|
||||
});
|
||||
|
||||
it("só teléfono e correo, sem nome do negócio nem do contato, segue recusada", () => {
|
||||
const resultado = lerPlanilhaDeLeads("Teléfono,Correo\n11999998888,a@b.co\n");
|
||||
expect(resultado).toEqual({
|
||||
erro: "A planilha precisa de uma coluna com o nome do negócio ou do contato. Encontrei: Teléfono, Correo.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+21
-8
@@ -39,15 +39,28 @@ import { precoParaCentavos } from "@/lib/schemas/produtos";
|
||||
* linha recusada com o motivo, nunca um chute.
|
||||
*/
|
||||
|
||||
/** Como cada coluna pode vir escrita. A primeira forma é a que a gente sugere. */
|
||||
/**
|
||||
* Como cada coluna pode vir escrita. A primeira forma é a que a gente sugere.
|
||||
* As de espanhol (`nombre`, `teléfono`, `correo`…) cumprem o que a tela promete
|
||||
* a quem a usa nesse idioma; a comparação tira acento e caixa dos dois lados.
|
||||
*/
|
||||
const COLUNAS: Record<string, readonly string[]> = {
|
||||
titulo: ["nome", "titulo", "título", "lead", "negocio", "negócio", "oportunidade", "empresa", "assunto"],
|
||||
contato: ["nome do contato", "contato", "responsavel", "responsável", "pessoa"],
|
||||
telefone: ["telefone", "celular", "whatsapp", "fone", "phone"],
|
||||
email: ["email", "e-mail"],
|
||||
descricao: ["descricao", "descrição", "observacao", "observação", "observacoes", "observações", "notas", "detalhes"],
|
||||
valor: ["valor", "preco", "preço", "ticket", "value"],
|
||||
origem: ["origem", "fonte", "canal", "source"],
|
||||
titulo: [
|
||||
"nome", "titulo", "título", "lead", "negocio", "negócio", "oportunidade", "empresa", "assunto",
|
||||
"nombre", "oportunidad", "asunto",
|
||||
],
|
||||
contato: [
|
||||
"nome do contato", "contato", "responsavel", "responsável", "pessoa",
|
||||
"nombre del contacto", "contacto", "responsable", "persona",
|
||||
],
|
||||
telefone: ["telefone", "celular", "whatsapp", "fone", "phone", "telefono", "movil"],
|
||||
email: ["email", "e-mail", "correo", "correo electronico"],
|
||||
descricao: [
|
||||
"descricao", "descrição", "observacao", "observação", "observacoes", "observações", "notas", "detalhes",
|
||||
"descripcion", "observacion", "observaciones", "detalles",
|
||||
],
|
||||
valor: ["valor", "preco", "preço", "ticket", "value", "precio", "importe", "monto"],
|
||||
origem: ["origem", "fonte", "canal", "source", "origen", "fuente"],
|
||||
etiquetas: ["tags", "etiquetas", "marcadores"],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* O teto de falhas de token do MCP — o que o endpoint não tinha (issue #1447).
|
||||
*
|
||||
* Por que esta cerca existe: `POST /api/mcp` respondia 401 a qualquer `dsk_...`
|
||||
* sem contar a recusa. Cada tentativa custava um SELECT em `api_tokens` e nada
|
||||
* registrava o fracasso, então varrer tokens saía de graça — sem teto, sem
|
||||
* rastro. A correção conta a falha em dois baldes (`lib/auth/rate-limit.ts`):
|
||||
* por ORIGEM (30 falhas / 5 min) e pelo VALOR APRESENTADO (5 falhas / 5 min,
|
||||
* chave = hash do valor apresentado, nunca o valor).
|
||||
*
|
||||
* O que estes casos travam — e que os vizinhos (`auth-resolve-api-token.test.ts`,
|
||||
* `auth-ator.test.ts`) não travam, porque lá cada caso é uma falha isolada:
|
||||
* 1. chute repetido cai no teto, e o teto é 429/-32004 (não 401);
|
||||
* 2. 30 chutes DIFERENTES do mesmo IP também caem — é o balde por origem que
|
||||
* pega quem troca de token a cada palpite;
|
||||
* 3. token válido em uso não paga imposto nenhum: sucesso não incrementa;
|
||||
* 4. trocar de IP não salva quem repete o MESMO valor — é o balde por valor
|
||||
* que pega a varredura distribuída;
|
||||
* 5. `lookup_failed` (banco fora) não debita nada: indisponibilidade nossa não
|
||||
* tranca cliente nenhum.
|
||||
*
|
||||
* Comando: npx vitest run lib/mcp/auth-teto-de-token.test.ts
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next/headers", () => ({ headers: vi.fn() }));
|
||||
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
|
||||
import type * as AuthModulo from "./auth";
|
||||
|
||||
let auth: typeof AuthModulo;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Contadores vivem no módulo: cada caso começa do zero.
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
auth = await import("./auth");
|
||||
});
|
||||
|
||||
/** Diz de onde a requisição vem. `clientIp` lê qualquer um dos headers do proxy. */
|
||||
function chamandoDe(ip: string): void {
|
||||
vi.mocked(headers).mockResolvedValue({ get: () => ip } as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supabase falso: o encadeamento inteiro (`select(...).eq(...).maybeSingle()`,
|
||||
* `update(...).eq(...)`) devolve a si mesmo e o `await` resolve no resultado
|
||||
* passado. O teste fica preso ao contrato (linha → dado), não ao formato da query.
|
||||
*/
|
||||
function bancoFalso(resultado: { data: unknown; error: { message: string } | null }): void {
|
||||
const cadeia = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_alvo, prop) {
|
||||
if (prop === "then") {
|
||||
return (resolve: (v: unknown) => unknown) => resolve(resultado);
|
||||
}
|
||||
return () => cadeia;
|
||||
},
|
||||
},
|
||||
) as never;
|
||||
vi.mocked(createAdminClient).mockReturnValue({ from: () => cadeia } as never);
|
||||
}
|
||||
|
||||
/** Linha de `api_tokens` como o lookup devolve (schema real, token morto ou vivo). */
|
||||
function linhaDeToken(extra: { revoked_at?: string | null; expires_at?: string | null } = {}) {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
organization_id: "22222222-2222-4222-8222-222222222222",
|
||||
scopes: ["mcp:read"],
|
||||
revoked_at: null,
|
||||
expires_at: null,
|
||||
created_by: "33333333-3333-4333-8333-333333333333",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const SEM_TOKEN = { data: null, error: null };
|
||||
|
||||
/** Uma tentativa de autenticação, como dado: `tipo: "ok"` ou o erro do MCP. */
|
||||
type Tentativa =
|
||||
| { tipo: "ok" }
|
||||
| { tipo: "falha"; mcpCode: number; httpStatus: number; message: string };
|
||||
|
||||
async function tentar(token: string | null, ip = "10.0.0.1"): Promise<Tentativa> {
|
||||
chamandoDe(ip);
|
||||
try {
|
||||
await auth.validateBearerToken(token === null ? null : `Bearer ${token}`);
|
||||
return { tipo: "ok" };
|
||||
} catch (err) {
|
||||
const e = err as { mcpCode?: number; httpStatus?: number; message?: string };
|
||||
return {
|
||||
tipo: "falha",
|
||||
mcpCode: e.mcpCode ?? NaN,
|
||||
httpStatus: e.httpStatus ?? NaN,
|
||||
message: e.message ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("validateBearerToken: teto de falhas (issue #1447)", () => {
|
||||
it("chute repetido: 5 recusas de 401 e a 6ª já é 429/-32004", async () => {
|
||||
bancoFalso(SEM_TOKEN);
|
||||
|
||||
const tentativas: Tentativa[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
tentativas.push(await tentar("dsk_chute_de_valor_fixo"));
|
||||
}
|
||||
|
||||
for (const recusa of tentativas.slice(0, 5)) {
|
||||
expect(recusa).toMatchObject({ tipo: "falha", mcpCode: -32001, httpStatus: 401 });
|
||||
}
|
||||
expect(tentativas[5]).toMatchObject({ tipo: "falha", mcpCode: -32004, httpStatus: 429 });
|
||||
// E continua barrado enquanto a janela não passa.
|
||||
expect(tentativas[6]).toMatchObject({ tipo: "falha", mcpCode: -32004, httpStatus: 429 });
|
||||
});
|
||||
|
||||
it("30 chutes DIFERENTES do mesmo IP caem pelo balde por origem", async () => {
|
||||
bancoFalso(SEM_TOKEN);
|
||||
|
||||
const tentativas: Tentativa[] = [];
|
||||
for (let i = 0; i < 32; i++) {
|
||||
tentativas.push(await tentar(`dsk_chute_${i}`));
|
||||
}
|
||||
|
||||
expect(tentativas[28]).toMatchObject({ tipo: "falha", mcpCode: -32001, httpStatus: 401 });
|
||||
expect(tentativas[30]).toMatchObject({ tipo: "falha", mcpCode: -32004, httpStatus: 429 });
|
||||
});
|
||||
|
||||
it("trocar de IP a cada palpite não salva quem repete o MESMO valor", async () => {
|
||||
bancoFalso(SEM_TOKEN);
|
||||
|
||||
const tentativas: Tentativa[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
// IP novo a cada tentativa: cada origem fica com 1 falha, longe do teto.
|
||||
tentativas.push(await tentar("dsk_mesmo_valor_em_ips_diferentes", `10.7.7.${i}`));
|
||||
}
|
||||
|
||||
expect(tentativas[4]).toMatchObject({ tipo: "falha", mcpCode: -32001, httpStatus: 401 });
|
||||
expect(tentativas[5]).toMatchObject({ tipo: "falha", mcpCode: -32004, httpStatus: 429 });
|
||||
});
|
||||
|
||||
it("token válido em uso não paga imposto: 40 chamadas seguidas, nenhuma barrada", async () => {
|
||||
bancoFalso({ data: linhaDeToken(), error: null });
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
expect(await tentar("dsk_token_valido", "10.5.5.5")).toEqual({ tipo: "ok" });
|
||||
}
|
||||
});
|
||||
|
||||
it("buraco de banco (lookup_failed) não tranca cliente: 40 falhas de 500 e zero 429", async () => {
|
||||
bancoFalso({ data: null, error: { message: "conexão recusada pelo pool" } });
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
expect(await tentar("dsk_token_valido", "10.6.6.6")).toMatchObject({
|
||||
tipo: "falha",
|
||||
mcpCode: -32603,
|
||||
httpStatus: 500,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("token expirado debita só o balde do valor: a 6ª repetição é 429, mesmo trocando de IP", async () => {
|
||||
bancoFalso({ data: linhaDeToken({ expires_at: "2020-01-01T00:00:00.000Z" }), error: null });
|
||||
|
||||
const tentativas: Tentativa[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
tentativas.push(await tentar("dsk_token_expirado", `10.8.8.${i}`));
|
||||
}
|
||||
|
||||
expect(tentativas[0]).toMatchObject({ tipo: "falha", mcpCode: -32001, httpStatus: 401 });
|
||||
expect(tentativas[4]).toMatchObject({ tipo: "falha", mcpCode: -32001, httpStatus: 401 });
|
||||
expect(tentativas[5]).toMatchObject({ tipo: "falha", mcpCode: -32004, httpStatus: 429 });
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { Actor } from "@/lib/api/handlers/types";
|
||||
import { registrarFalhaDeToken, tokenFailureLimited } from "@/lib/auth/rate-limit";
|
||||
import type { Role } from "@/lib/auth/types";
|
||||
import { ROLE_RANK } from "@/lib/auth/types";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
@@ -162,19 +163,45 @@ export async function resolveApiToken(plaintext: string): Promise<ResolvedApiTok
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mensagem do teto de falhas. Escrita para quem lê a resposta — quase sempre um
|
||||
* modelo: o texto é o único sinal útil depois do bloqueio. Nada de contador,
|
||||
* nada de "quantas faltam": a resposta não diz se o token existe nem quanto
|
||||
* resta da janela.
|
||||
*/
|
||||
const TETO_DE_TOKEN_MSG =
|
||||
"Too many failed token attempts. Wait a few minutes before retrying and send a valid `dsk_` API token — if yours was revoked or expired, issue a new one.";
|
||||
|
||||
export async function validateBearerToken(
|
||||
authHeader: string | null,
|
||||
): Promise<McpAuthResult> {
|
||||
const plaintext = extractBearer(authHeader);
|
||||
if (!plaintext) {
|
||||
// Cabeçalho torto é o primeiro palpite de quem varre: conta antes de sair.
|
||||
await registrarFalhaDeToken(null);
|
||||
throw new McpAuthError(-32001, 401, "Missing or malformed Authorization header.");
|
||||
}
|
||||
|
||||
// O teto vem ANTES de resolver o token: é esta linha que tira o custo zero da
|
||||
// tentativa — sem ela cada `dsk_` chutado custa um SELECT em `api_tokens` que
|
||||
// ninguém conta, e varrer tokens sai de graça (issue #1447).
|
||||
if (await tokenFailureLimited(plaintext)) {
|
||||
throw new McpAuthError(-32004, 429, TETO_DE_TOKEN_MSG);
|
||||
}
|
||||
|
||||
let resolved: ResolvedApiToken;
|
||||
try {
|
||||
resolved = await resolveApiToken(plaintext);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiTokenError) {
|
||||
if (err.reason !== "lookup_failed") {
|
||||
// Chute (malformado/desconhecido) debita o balde por ORIGEM; token real
|
||||
// e morto (revogado/expirado) debita só o do valor apresentado — ver
|
||||
// `registrarFalhaDeToken`. `lookup_failed` é falha NOSSA: não debita.
|
||||
await registrarFalhaDeToken(plaintext, {
|
||||
contaNoIp: err.reason === "malformed" || err.reason === "not_found",
|
||||
});
|
||||
}
|
||||
throw new McpAuthError(
|
||||
err.reason === "lookup_failed" ? -32603 : -32001,
|
||||
err.reason === "lookup_failed" ? 500 : 401,
|
||||
|
||||
@@ -188,7 +188,9 @@ export const crmCreateLead: McpToolDefinition<typeof createInputShape> = {
|
||||
description: input.description ?? null,
|
||||
contact_id: input.contact_id ?? null,
|
||||
value_cents: input.value_cents ?? null,
|
||||
currency: input.currency ?? "BRL",
|
||||
// Ausente, a moeda é a da organização (`createLeadHandler` a lê): um
|
||||
// literal "BRL" aqui fazia o agente criar em real numa empresa em euro.
|
||||
currency: input.currency,
|
||||
owner_user_id: input.owner_user_id ?? null,
|
||||
owner_agent_id: input.owner_agent_id ?? null,
|
||||
expected_close_date: input.expected_close_date ?? null,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
formatCents,
|
||||
MOEDAS_SERVIDAS,
|
||||
MOEDA_PADRAO,
|
||||
simboloDaMoeda,
|
||||
} from "./money";
|
||||
|
||||
describe("parseReaisToCents", () => {
|
||||
@@ -78,6 +79,11 @@ describe("formatCents", () => {
|
||||
// Angola — `formatadorDa` maximiza `und-AO` para `pt-AO` e é o ICU que
|
||||
// decide, não uma tabela nossa.
|
||||
expect(semNbsp(formatCents(24990, "AOA"))).toBe("249,90 Kz");
|
||||
// Euro: moeda sem país. A maximização de `und-EU` daria `€249.90`, a
|
||||
// convenção irlandesa; Portugal, Espanha, França, Alemanha e Itália
|
||||
// escrevem assim.
|
||||
expect(semNbsp(formatCents(24990, "EUR"))).toBe("249,90 €");
|
||||
expect(semNbsp(formatCents(149700, "EUR"))).toBe("1497,00 €");
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -151,6 +157,11 @@ describe("MOEDAS_SERVIDAS — a lista que a tela oferece", () => {
|
||||
expect(MOEDAS_SERVIDAS).toContain("USD");
|
||||
});
|
||||
|
||||
it("serve o euro, com o símbolo que o seletor mostra", () => {
|
||||
expect(MOEDAS_SERVIDAS).toContain("EUR");
|
||||
expect(simboloDaMoeda("EUR")).toBe("€");
|
||||
});
|
||||
|
||||
it("e o padrão de quem não escolheu segue sendo o real", () => {
|
||||
expect(MOEDA_PADRAO).toBe("BRL");
|
||||
});
|
||||
|
||||
+32
-11
@@ -108,8 +108,9 @@ export function formatCentsUSD(cents: number): string {
|
||||
* `es-Latn-MX`, e o CLDR indexa o símbolo de moeda por `es-MX` — com o script
|
||||
* no meio, o MXN volta a sair como `"249,90 MXN"`. Medido nos dois sentidos.
|
||||
*
|
||||
* Moeda sem país (EUR→`en-EU`, XOF→`en-XO`) e código desconhecido caem em
|
||||
* `en-US`, que escreve o código ISO e não mente sobre a unidade.
|
||||
* Código desconhecido cai em `en-US`, que escreve o código ISO e não mente
|
||||
* sobre a unidade. Moeda SEM país é a exceção que a regra acima não alcança, e
|
||||
* tem locale declarado em `LOCALE_DA_MOEDA_SEM_PAIS`.
|
||||
*
|
||||
* ─── `_cents` nem sempre é centésimo ───────────────────────────────────────
|
||||
*
|
||||
@@ -153,17 +154,32 @@ export function formatCentsUSD(cents: number): string {
|
||||
*/
|
||||
const formatadores = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
/**
|
||||
* Moeda cujo código ISO não começa por um país. `EU` maximiza para `en-EU`, e
|
||||
* `en-EU` escreve `€249.90` — ponto decimal e símbolo na frente, a convenção da
|
||||
* Irlanda. Português, espanhol, francês, alemão e italiano escrevem `249,90 €`,
|
||||
* e é o que `pt-PT` devolve (medido no ICU do Node 22, junto com `de-DE`,
|
||||
* `fr-FR`, `es-ES` e `it-IT`).
|
||||
*
|
||||
* ponytail: um formato só para a zona euro. Holanda (`€ 249,90`) e Irlanda
|
||||
* (`€249.90`) leem a convenção vizinha; quando `organizations.country` puder
|
||||
* escolher o locale, é ele que decide, e esta tabela some.
|
||||
*/
|
||||
const LOCALE_DA_MOEDA_SEM_PAIS: Readonly<Record<string, string>> = { EUR: "pt-PT" };
|
||||
|
||||
function formatadorDa(moeda: string): Intl.NumberFormat {
|
||||
const cacheado = formatadores.get(moeda);
|
||||
if (cacheado) return cacheado;
|
||||
|
||||
let locale = "en-US";
|
||||
try {
|
||||
const provavel = new Intl.Locale(`und-${moeda.slice(0, 2)}`).maximize();
|
||||
const tag = `${provavel.language}-${provavel.region}`;
|
||||
if (Intl.NumberFormat.supportedLocalesOf(tag).length > 0) locale = tag;
|
||||
} catch {
|
||||
// Região que o ICU não conhece: fica o padrão.
|
||||
let locale = LOCALE_DA_MOEDA_SEM_PAIS[moeda] ?? "en-US";
|
||||
if (!LOCALE_DA_MOEDA_SEM_PAIS[moeda]) {
|
||||
try {
|
||||
const provavel = new Intl.Locale(`und-${moeda.slice(0, 2)}`).maximize();
|
||||
const tag = `${provavel.language}-${provavel.region}`;
|
||||
if (Intl.NumberFormat.supportedLocalesOf(tag).length > 0) locale = tag;
|
||||
} catch {
|
||||
// Região que o ICU não conhece: fica o padrão.
|
||||
}
|
||||
}
|
||||
|
||||
const novo = new Intl.NumberFormat(locale, { style: "currency", currency: moeda });
|
||||
@@ -215,7 +231,7 @@ export function formatCents(cents: number, moeda: string): string {
|
||||
* ABERTO que a doutrina de modelagem descreve. O conjunto vive só aqui, no
|
||||
* TypeScript.
|
||||
*
|
||||
* As quatro têm subunidade de 2 casas, então nenhuma esbarra na ressalva de
|
||||
* As cinco têm subunidade de 2 casas, então nenhuma esbarra na ressalva de
|
||||
* unidades menores de `formatCents`. Acrescentar JPY ou CLP funciona — o
|
||||
* formatador já os cobre —, mas exige olhar `precoParaCentavos`, que ainda
|
||||
* multiplica por 100 na leitura do que a pessoa digita.
|
||||
@@ -226,8 +242,13 @@ export function formatCents(cents: number, moeda: string): string {
|
||||
* `maximumFractionDigits` responde 2, então a régua de centavos vale. Entrar na
|
||||
* lista NÃO muda o padrão de ninguém — `MOEDA_PADRAO` continua `BRL`, e é isso
|
||||
* que o `default` da coluna grava em quem não escolheu.
|
||||
*
|
||||
* `EUR` entrou pelo mesmo motivo, para quem opera em Portugal e no resto da
|
||||
* zona euro. É o caso que a maximização não resolve (ver
|
||||
* `LOCALE_DA_MOEDA_SEM_PAIS`): sem a exceção, `formatCents(24990, "EUR")`
|
||||
* sairia `€249.90`, e a varredura de degradação deixaria passar.
|
||||
*/
|
||||
export const MOEDAS_SERVIDAS = ["AOA", "BRL", "MXN", "USD"] as const;
|
||||
export const MOEDAS_SERVIDAS = ["AOA", "BRL", "EUR", "MXN", "USD"] as const;
|
||||
export type MoedaServida = (typeof MOEDAS_SERVIDAS)[number];
|
||||
|
||||
/** O que o `default` da coluna grava quando ninguém escolheu. */
|
||||
|
||||
+63
-9
@@ -10,7 +10,29 @@ import { cookieSecure } from "@/lib/supabase/cookie-secure";
|
||||
import { cookies } from "next/headers";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
export async function createClient() {
|
||||
/**
|
||||
* Tudo o que vale para TODO cookie deste cliente, menos o `sameSite` — que é
|
||||
* justamente o que muda entre a sessão e o verificador de PKCE (ver
|
||||
* `createClientDeEntradaComGoogle`).
|
||||
*
|
||||
* ⚠️ Precisa ser FUNÇÃO, não constante de módulo: `cookieSecure()` lê
|
||||
* `env.NEXT_PUBLIC_APP_URL` em runtime, e quem importa este arquivo (direto ou
|
||||
* por `lib/audit`) costuma mockar `@/lib/env` no teste. Constante de módulo
|
||||
* executa no IMPORT — antes do mock existir — e derruba a suíte inteira com
|
||||
* "Cannot access 'envMock' before initialization".
|
||||
*/
|
||||
function opcoesDeCookie(sameSite: "strict" | "lax") {
|
||||
// D-01.01: cookie name canônico alinhado ao middleware.
|
||||
return {
|
||||
name: "sb-deskcomm-auth",
|
||||
sameSite,
|
||||
httpOnly: true,
|
||||
secure: cookieSecure(),
|
||||
path: "/",
|
||||
};
|
||||
}
|
||||
|
||||
async function clienteDeServidor(sameSite: "strict" | "lax") {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
return createServerClient(env.NEXT_PUBLIC_SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_ANON_KEY, {
|
||||
@@ -29,13 +51,45 @@ export async function createClient() {
|
||||
}
|
||||
},
|
||||
},
|
||||
// D-01.01: cookie name canônico alinhado ao middleware.
|
||||
cookieOptions: {
|
||||
name: "sb-deskcomm-auth",
|
||||
sameSite: "strict",
|
||||
httpOnly: true,
|
||||
secure: cookieSecure(),
|
||||
path: "/",
|
||||
},
|
||||
cookieOptions: opcoesDeCookie(sameSite),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createClient() {
|
||||
return clienteDeServidor("strict");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cliente para INICIAR a entrada com Google (OAuth) — de propósito com
|
||||
* `sameSite: "lax"`, e só por causa do cookie do VERIFICADOR de PKCE.
|
||||
*
|
||||
* ─── Por que o jar do verificador não pode ser Strict ───────────────────────
|
||||
*
|
||||
* Com `flowType: "pkce"` (que `createServerClient` força — createServerClient.js:33),
|
||||
* `signInWithOAuth` sorteia um verificador e o grava no jar de cookies DESTE
|
||||
* cliente, com estas mesmas opções. No `createClient` de sempre elas incluem
|
||||
* `sameSite: "strict"`.
|
||||
*
|
||||
* A volta do Google é navegação de outro site até o fim: o navegador sai de
|
||||
* `accounts.google.com`, o GoTrue responde 302 e o `redirect_to` cai aqui. O
|
||||
* navegador NÃO manda cookie `SameSite=Strict` numa navegação cross-site, então
|
||||
* o verificador não chega ao `/auth/callback` e a troca do `code` por sessão
|
||||
* falha com `PKCE code verifier not found in storage` — o defeito medido na
|
||||
* issue #1388. Com `Lax`, o cookie viaja nessa navegação (GET de topo) e a
|
||||
* troca fecha.
|
||||
*
|
||||
* ─── O que fica frouxo, e o que continua Strict ─────────────────────────────
|
||||
*
|
||||
* Este cliente existe para UMA chamada: `signInWithOAuth`. Depois dela o
|
||||
* verificador é segredo de uso único — morre consumido na troca, no mesmo
|
||||
* request em que é lido. Quem TROCA o `code` por sessão é `/auth/callback` com
|
||||
* o `createClient` de sempre: é ele que grava os cookies de SESSÃO, e esses
|
||||
* seguem `Strict`, como todo o resto do produto.
|
||||
*
|
||||
* (O default do próprio `@supabase/ssr` é `lax` — constants.js:6. Quem endureceu
|
||||
* para `strict` foi este arquivo; a entrada com Google é a exceção necessária,
|
||||
* não uma mudança de postura.)
|
||||
*/
|
||||
export async function createClientDeEntradaComGoogle() {
|
||||
return clienteDeServidor("lax");
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const EMAIL = process.argv[2] || "e2e-agent@deskcomm.test";
|
||||
await page.locator("#password").pressSequentially(PASSWORD, { delay: 30 });
|
||||
console.log("EMAIL VAL:", await page.locator("#email").inputValue());
|
||||
console.log("PASS LEN:", (await page.locator("#password").inputValue()).length);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForTimeout(6000);
|
||||
console.log("FINAL URL:", page.url());
|
||||
console.log("COOKIES:", (await ctx.cookies()).map((c) => c.name).join(","));
|
||||
|
||||
@@ -28,7 +28,7 @@ async function main(): Promise<void> {
|
||||
await page.goto(`${APP}/login`);
|
||||
await page.locator("#email").fill(creds.users.manager!.email);
|
||||
await page.locator("#password").fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app\//);
|
||||
|
||||
await page.goto(`${APP}/app/webhooks`);
|
||||
|
||||
@@ -45,7 +45,7 @@ async function login(context: BrowserContext, email: string): Promise<void> {
|
||||
await page.locator("#password").focus();
|
||||
await page.waitForTimeout(200);
|
||||
await page.locator("#password").pressSequentially(PASSWORD, { delay: 25 });
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
// Wait for navigation away from /login (router.replace after server action)
|
||||
try {
|
||||
// Allow either /app/* or /login/mfa* — both indicate successful auth
|
||||
|
||||
@@ -44,7 +44,7 @@ async function login(context: BrowserContext, email: string): Promise<void> {
|
||||
await page.locator("#password").focus();
|
||||
await page.waitForTimeout(200);
|
||||
await page.locator("#password").pressSequentially(PASSWORD, { delay: 25 });
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
try {
|
||||
await page.waitForURL(
|
||||
(url) => /\/app\b|\/login\/mfa/.test(url.toString()),
|
||||
|
||||
@@ -44,7 +44,7 @@ async function login(context: BrowserContext, email: string): Promise<void> {
|
||||
await page.locator("#password").focus();
|
||||
await page.waitForTimeout(200);
|
||||
await page.locator("#password").pressSequentially(PASSWORD, { delay: 25 });
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
try {
|
||||
await page.waitForURL(
|
||||
(url) => /\/app\b|\/login\/mfa/.test(url.toString()),
|
||||
|
||||
@@ -47,7 +47,7 @@ async function login(context: BrowserContext, email: string): Promise<void> {
|
||||
await page.locator("#password").focus();
|
||||
await page.waitForTimeout(200);
|
||||
await page.locator("#password").pressSequentially(PASSWORD, { delay: 25 });
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
try {
|
||||
await page.waitForURL(
|
||||
(url) => /\/app\b|\/login\/mfa/.test(url.toString()),
|
||||
|
||||
@@ -42,7 +42,7 @@ async function login(context: BrowserContext, email: string): Promise<void> {
|
||||
await page.locator("#password").focus();
|
||||
await page.waitForTimeout(200);
|
||||
await page.locator("#password").pressSequentially(PASSWORD, { delay: 25 });
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
try {
|
||||
await page.waitForURL(
|
||||
(url) => /\/app\b|\/login\/mfa/.test(url.toString()),
|
||||
|
||||
@@ -32,7 +32,7 @@ async function loginAndGetApi(): Promise<{ api: APIRequestContext; close: () =>
|
||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#email").pressSequentially(ADMIN_EMAIL, { delay: 20 });
|
||||
await page.locator("#password").pressSequentially(PASSWORD, { delay: 20 });
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
try {
|
||||
await page.waitForURL((u) => /\/app\b|\/login\/mfa/.test(u.toString()), { timeout: 20_000 });
|
||||
} catch { /* tolerate */ }
|
||||
|
||||
@@ -48,7 +48,7 @@ async function main() {
|
||||
await page.goto(`${baseURL}/login`);
|
||||
await page.getByLabel("Email").fill(email);
|
||||
await page.getByLabel("Senha").fill(password);
|
||||
await page.getByRole("button", { name: "Entrar" }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/login\/mfa/, { timeout: 20_000 });
|
||||
console.log("etapa: /login/mfa alcançado");
|
||||
// Input OTP com auto-submit no onComplete — digitar via teclado, não fill().
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* A CREDENCIAL DO GOOGLE, CADASTRADA PELA TELA — sem SSH, sem editar `.env`.
|
||||
*
|
||||
* ═══ POR QUE ESTA SPEC EXISTE (issue #370) ═══
|
||||
*
|
||||
* `/admin/google` (PR #369) entrou sem prova pela tela: o defeito que ela
|
||||
* fecha era justamente de UX para quem não programa — o cartão da Agenda
|
||||
* mandava editar `GOOGLE_CALENDAR_CLIENT_ID`/`_SECRET` por SSH na VPS.
|
||||
* Consertar um problema de leigo sem provar que um leigo consegue usar o
|
||||
* conserto deixa o buraco no mesmo lugar, só mais fundo (DoD 12).
|
||||
*
|
||||
* `tests/invariants/credencial-do-google-e-server-side.test.ts` prova que
|
||||
* `platform_google_oauth` é ilegível pelo PostgREST; `tests/unit/agenda-
|
||||
* google-credencial-do-banco.test.ts` prova a resolução. Nenhum dos dois
|
||||
* dirige um navegador — é a metade que falta.
|
||||
*
|
||||
* ═══ A ASSERÇÃO CENTRAL, E POR QUE NÃO É DETALHE DE UI ═══
|
||||
*
|
||||
* O `client_secret` é o que permite trocar códigos e refresh tokens EM NOME
|
||||
* DESTA INSTALAÇÃO — isto é, ler a agenda de todos os atendentes que
|
||||
* conectaram. `_form.tsx` nunca recebe o valor de volta do servidor (só um
|
||||
* booleano, `temSegredoSalvo`); o caso (1) abaixo prova isso pela TELA — o
|
||||
* campo vazio depois de recarregar, e o HTML servido sem o segredo em
|
||||
* lugar nenhum — porque é a garantia que o comentário do componente promete
|
||||
* e que só um teste que TENTA ler de volta pode furar.
|
||||
*
|
||||
* ═══ QUEM É QUEM ═══
|
||||
*
|
||||
* O objeto é a INSTALAÇÃO, não a organização (mesmo argumento de `/admin/
|
||||
* marca`): só o DONO DO SERVIDOR (`platform_admins`) alcança `/admin/google`.
|
||||
* Um admin de TENANT que tentar entrar cai no gate de `requirePlatformAdmin()`
|
||||
* — `redirect("/admin/forbidden")` — antes de a página rodar (caso 2).
|
||||
*
|
||||
* ═══ O QUE ESTA SPEC NÃO PROVA ═══
|
||||
*
|
||||
* - A instalação SEM chave mestra de cifra (`private.app_secrets` sem
|
||||
* `nuvemshop_oauth_key`): o CI grava essa chave para o banco do e2e inteiro
|
||||
* (`.github/workflows/e2e.yml`), e desarmá-la aqui contaminaria as outras
|
||||
* specs de Google que compartilham o mesmo banco sem reset. Fica registrado
|
||||
* na issue como caso adjacente, não como bloqueio deste PR.
|
||||
* - A troca de código por token contra o Google de verdade (webjs/consentimento)
|
||||
* — isso é `agenda-google-volta-do-consentimento.spec.ts` e irmãs.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { credenciaisSupabaseDeTeste } from "../../scripts/lib/env-de-teste";
|
||||
import { lerCreds, loginComoAdmin, loginComoDono } from "./helpers/login-admin";
|
||||
import { afirmarDonoDoServidor } from "./utils/precondicao";
|
||||
|
||||
const CLIENT_ID_DE_TESTE = "000000000000-e2e370testecredencial.apps.googleusercontent.com";
|
||||
const CLIENT_SECRET_DE_TESTE = "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ade";
|
||||
|
||||
const { url, serviceRole } = credenciaisSupabaseDeTeste();
|
||||
const admin = createClient(url, serviceRole, { auth: { autoRefreshToken: false, persistSession: false } });
|
||||
|
||||
const EVIDENCIA = path.join(process.cwd(), "evidence", "admin-credencial-google");
|
||||
function evidencia(nome: string): string {
|
||||
fs.mkdirSync(EVIDENCIA, { recursive: true });
|
||||
return path.join(EVIDENCIA, nome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apaga a linha singleton — a PRECONDIÇÃO que este arquivo monta para si
|
||||
* mesmo, e a limpeza que devolve o banco compartilhado ao estado anterior.
|
||||
* Sem isto o caso (1) reprovaria numa segunda execução ("já cadastrada" em
|
||||
* vez de "não cadastrada"), e as duas leituras (`configuracaoDoGoogle()`
|
||||
* memoizado por processo) ficariam com uma linha que nenhuma outra spec
|
||||
* escreveu.
|
||||
*/
|
||||
async function limparCredencial(): Promise<void> {
|
||||
const { error } = await admin.from("platform_google_oauth").delete().eq("id", 1);
|
||||
if (error) throw new Error(`limparCredencial: ${error.message}`);
|
||||
}
|
||||
|
||||
test.describe("Credencial do Google da instalação, pela tela (#370)", () => {
|
||||
test.beforeAll(async () => {
|
||||
await afirmarDonoDoServidor(lerCreds().users.dono!.email);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await limparCredencial();
|
||||
});
|
||||
|
||||
test("(1) o dono cadastra pela tela, o segredo não volta, e o cartão da Agenda para de pedir SSH", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await limparCredencial();
|
||||
await loginComoDono(page, lerCreds());
|
||||
|
||||
// ── Estado inicial: nada cadastrado, nada no .env (o CI não define
|
||||
// GOOGLE_CALENDAR_CLIENT_ID/_SECRET) ────────────────────────────────────
|
||||
await page.goto("/admin/google");
|
||||
await expect(page.getByRole("heading", { name: /google agenda desta instalação/i })).toBeVisible();
|
||||
await expect(page.locator("#client-id")).toHaveValue("");
|
||||
await expect(
|
||||
page.getByTestId("google-tem-no-ambiente"),
|
||||
"o .env não deveria ter credencial nesta instalação de teste",
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByText(/nunca configurado por aqui/i)).toBeVisible();
|
||||
await expect(page.getByTestId("google-salvar")).toBeDisabled();
|
||||
await page.screenshot({ path: evidencia("1-nao-cadastrada.png"), fullPage: true });
|
||||
|
||||
// ── Cadastro pela tela ───────────────────────────────────────────────────
|
||||
await page.getByTestId("google-client-id").fill(CLIENT_ID_DE_TESTE);
|
||||
await page.getByTestId("google-client-secret").fill(CLIENT_SECRET_DE_TESTE);
|
||||
await expect(page.getByTestId("google-salvar")).toBeEnabled();
|
||||
await page.getByTestId("google-salvar").click();
|
||||
await expect(page.getByText(/credenciais do google salvas/i)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// ── A ASSERÇÃO CENTRAL: o segredo NÃO volta, nem recarregando ───────────
|
||||
await page.reload();
|
||||
await expect(page.getByText(/nunca configurado por aqui/i)).toHaveCount(0);
|
||||
await expect(page.locator("#client-id")).toHaveValue(CLIENT_ID_DE_TESTE);
|
||||
await expect(
|
||||
page.locator("#client-secret"),
|
||||
"o client_secret voltou preenchido — ele nunca deve ser devolvido pelo servidor",
|
||||
).toHaveValue("");
|
||||
await expect(page.getByPlaceholder(/já cadastrada/i)).toBeVisible();
|
||||
await page.screenshot({ path: evidencia("2-cadastrada-segredo-nao-volta.png"), fullPage: true });
|
||||
|
||||
const html = await page.content();
|
||||
expect(
|
||||
html.includes(CLIENT_SECRET_DE_TESTE),
|
||||
"o client_secret inteiro apareceu no HTML servido — ele nunca deve chegar ao navegador",
|
||||
).toBe(false);
|
||||
|
||||
// ── O efeito visível para quem atende: o cartão da Agenda para de pedir
|
||||
// SSH e passa a oferecer "Conectar Google" ──────────────────────────────
|
||||
await page.goto("/app/agenda");
|
||||
// A rota tem `loading.tsx` (Suspense do App Router): medido nesta spec,
|
||||
// `getByTestId("conectar-google")` resolve a DOIS elementos por uma janela
|
||||
// curta logo após `goto` — uma cópia com `hidden` num ancestral, da
|
||||
// hidratação do streaming ainda assentando. `networkidle` espera esse
|
||||
// assentamento antes de medir; sem ele, a asserção de visibilidade reprova
|
||||
// por "strict mode violation" pegando o instante errado.
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(
|
||||
page.getByTestId("google-nao-configurado"),
|
||||
"o cartão da Agenda continuou dizendo que falta configurar, depois do cadastro pela tela",
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("conectar-google")).toHaveCount(1);
|
||||
await expect(page.getByTestId("conectar-google")).toBeVisible();
|
||||
await page.screenshot({ path: evidencia("3-cartao-da-agenda-oferece-conectar.png"), fullPage: true });
|
||||
});
|
||||
|
||||
test("(2) administrador de ORGANIZAÇÃO não alcança /admin/google — cai no gate antes da página", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Único caso com login fresco (papel outro): paga a janela do TOTP.
|
||||
test.setTimeout(90_000);
|
||||
await loginComoAdmin(page, lerCreds());
|
||||
|
||||
await page.goto("/admin/google");
|
||||
// `requirePlatformAdmin()` redireciona para `/admin/forbidden` ANTES de a
|
||||
// página rodar `notFound()` — o gate do layout, não o da página, é quem
|
||||
// decide para quem não é dono do servidor.
|
||||
await expect(page).toHaveURL(/\/admin\/forbidden/);
|
||||
await expect(page.getByRole("heading", { name: /acesso negado/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -160,7 +160,7 @@ test("digitar o nome do perfil do WhatsApp acha o contato — e ele aparece COM
|
||||
await page.goto("/login");
|
||||
await page.locator("#email").fill(usuario.email);
|
||||
await page.locator("#password").fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: ESPERA });
|
||||
|
||||
const quemSeraAtendido = await abrirONovoAgendamento(page);
|
||||
|
||||
@@ -55,7 +55,7 @@ async function entrar(page: import("@playwright/test").Page, creds: Creds) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(usuario.email);
|
||||
await page.getByLabel(/senha/i).fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ async function entrar(page: import("@playwright/test").Page, creds: Creds) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(usuario.email);
|
||||
await page.getByLabel(/senha/i).fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ async function login(page: Page, f: Fixture) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(f.email);
|
||||
await page.getByLabel(/senha/i).fill(password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(?:\/|$)/, { timeout: 60_000 });
|
||||
}
|
||||
async function book(page: Page, f: Fixture) {
|
||||
|
||||
@@ -117,7 +117,7 @@ async function login(page: Page, email: string) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(email);
|
||||
await page.getByLabel(/senha/i).fill(password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(?:\/|$)/, { timeout: 60000 });
|
||||
}
|
||||
async function saveCalendars(page: Page) {
|
||||
|
||||
@@ -53,7 +53,7 @@ async function entrar(page: import("@playwright/test").Page, creds: Creds) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(usuario.email);
|
||||
await page.getByLabel(/senha/i).fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ async function entrar(page: Page, creds: Creds) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(usuario.email);
|
||||
await page.getByLabel(/senha/i).fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ async function entrar(page: Page, creds: Creds) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(usuario.email);
|
||||
await page.getByLabel(/senha/i).fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
|
||||
await page.goto("/app/agenda");
|
||||
await expect(page.getByTestId("tela-agenda")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
@@ -127,7 +127,7 @@ test("marcar um horário pela tela e vê-lo aparecer na grade — sem recarregar
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(usuario.email);
|
||||
await page.getByLabel(/senha/i).fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
|
||||
|
||||
await page.goto("/app/agenda");
|
||||
|
||||
@@ -116,7 +116,7 @@ async function entrar(page: Page, creds: Creds) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(usuario.email);
|
||||
await page.getByLabel(/senha/i).fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
|
||||
await page.goto("/app/agenda");
|
||||
await expect(page.getByTestId("tela-agenda")).toBeVisible({ timeout: 25_000 });
|
||||
|
||||
@@ -67,7 +67,7 @@ async function entrar(page: Page, creds: Creds) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(usuario.email);
|
||||
await page.getByLabel(/senha/i).fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
|
||||
await page.goto("/app/agenda");
|
||||
await expect(page.getByTestId("tela-agenda")).toBeVisible({ timeout: 25_000 });
|
||||
|
||||
@@ -88,7 +88,7 @@ async function entrar(page: Page, creds: Creds) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(usuario.email);
|
||||
await page.getByLabel(/senha/i).fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ async function entrar(page: Page, email: string, senha: string): Promise<void> {
|
||||
await page.goto("/login");
|
||||
await page.locator("#email").fill(email);
|
||||
await page.locator("#password").fill(senha);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: ESPERA });
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ async function entrar(page: Page, creds: Creds) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(usuario.email);
|
||||
await page.getByLabel(/senha/i).fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ async function login(page: Page, email: string) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(email);
|
||||
await page.getByLabel(/senha/i).fill(password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(?:\/|$)/, { timeout: 60000 });
|
||||
}
|
||||
async function detail(page: Page, id: string, title: string) {
|
||||
|
||||
@@ -94,7 +94,7 @@ async function entrar(page: Page, email: string) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(email);
|
||||
await page.getByLabel(/senha/i).fill(senha);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ async function entrar(page: import("@playwright/test").Page, creds: Creds): Prom
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(usuario.email);
|
||||
await page.getByLabel(/senha/i).fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
|
||||
await page.goto("/app/agenda");
|
||||
await expect(page.getByTestId("tela-agenda")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
@@ -69,7 +69,7 @@ async function entrar(page: import("@playwright/test").Page, creds: Creds) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(usuario.email);
|
||||
await page.getByLabel(/senha/i).fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ async function entrar(page: Page, creds: Creds) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(usuario.email);
|
||||
await page.getByLabel(/senha/i).fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ test.describe("o agente marca consulta", () => {
|
||||
await page.goto(`${APP_URL}/login`);
|
||||
await page.getByLabel(/e-?mail/i).fill(usuario.email);
|
||||
await page.getByLabel(/senha/i).fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
// Timeout explícito: o padrão do Playwright é curto para um login que sobe sessão.
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ async function login(page: Page, quem: "manager"): Promise<void> {
|
||||
await page.goto(`${APP_URL}/login`);
|
||||
await page.getByLabel(/e-?mail/i).fill(creds.users[quem]!.email);
|
||||
await page.getByLabel(/senha/i).fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ test.describe("auth flow", () => {
|
||||
await page.goto("/login");
|
||||
await page.locator("#email").fill("nobody@example.com");
|
||||
await page.locator("#password").fill("wrong-password-xyz");
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
// Wait for either an inline error or that we did NOT navigate to /app
|
||||
await page.waitForTimeout(1500);
|
||||
expect(page.url()).not.toMatch(/\/app\//);
|
||||
@@ -27,7 +27,7 @@ test.describe("auth flow", () => {
|
||||
await expect(page.locator("#password")).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
// Next focusable is the submit button
|
||||
const submit = page.getByRole("button", { name: /entrar/i });
|
||||
const submit = page.getByRole("button", { name: "Entrar", exact: true });
|
||||
await expect(submit).toBeFocused();
|
||||
});
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ async function login(page: Page, email: string): Promise<void> {
|
||||
await page.goto(`${APP_URL}/login`);
|
||||
await page.locator("#email").fill(email);
|
||||
await page.locator("#password").fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app\//);
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ async function login(page: Page, f: Fixture) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(f.email);
|
||||
await page.getByLabel(/senha/i).fill(password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(?:\/|$)/, { timeout: 60_000 });
|
||||
}
|
||||
async function capture(page: Page, target: Locator, info: TestInfo, name: string) {
|
||||
|
||||
@@ -142,7 +142,7 @@ async function preencheLogin(page: Page, email: string): Promise<void> {
|
||||
await expect(page.locator("#email")).toBeVisible({ timeout: ESPERA });
|
||||
await page.locator("#email").fill(email);
|
||||
await page.locator("#password").fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
}
|
||||
|
||||
/** Ver o comentário do helper homônimo em `aviso-de-caso-no-whatsapp.spec.ts`:
|
||||
|
||||
@@ -78,7 +78,7 @@ async function preencheLogin(page: Page, email: string): Promise<void> {
|
||||
await expect(page.locator("#email")).toBeVisible({ timeout: ESPERA });
|
||||
await page.locator("#email").fill(email);
|
||||
await page.locator("#password").fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
}
|
||||
|
||||
async function loginSimples(page: Page, email: string): Promise<void> {
|
||||
|
||||
@@ -46,7 +46,7 @@ async function login(page: Page): Promise<void> {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(email);
|
||||
await page.getByLabel(/senha/i).fill(senha);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(?:\/|$)/, { timeout: 60_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ async function login(page: Page, email: string): Promise<void> {
|
||||
await page.goto("/login");
|
||||
await page.locator("#email").fill(email);
|
||||
await page.locator("#password").fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(\/|$)/);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ async function login(page: Page, role: string) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(user.email);
|
||||
await page.getByLabel(/senha/i).fill(password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(?:\/|$)/, { timeout: 60_000 });
|
||||
}
|
||||
const row = (page: Page, title: string) => page.getByTestId("inbox-item").filter({ hasText: title });
|
||||
|
||||
@@ -40,7 +40,7 @@ async function login(page: Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(usuario.email);
|
||||
await page.getByLabel(/senha/i).fill(password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(?:\/|$)/, { timeout: 60_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ async function login(page: Page, email: string, senha: string): Promise<void> {
|
||||
await page.goto("/login");
|
||||
await page.locator("#email").fill(email);
|
||||
await page.locator("#password").fill(senha);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app/, { timeout: 60_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ async function entrar(page: Page, email: string) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/e-?mail/i).fill(email);
|
||||
await page.getByLabel(/senha/i).fill(senha);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app(?:\/|$)/, { timeout: 60_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ async function login(page: Page): Promise<void> {
|
||||
await page.goto("/login");
|
||||
await page.locator("#email").fill(creds.users.manager!.email);
|
||||
await page.locator("#password").fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ async function login(page: Page): Promise<void> {
|
||||
await page.goto("/login");
|
||||
await page.locator("#email").fill(creds.users.manager!.email);
|
||||
await page.locator("#password").fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ async function login(page: Page, email: string): Promise<void> {
|
||||
await expect(page.locator("#email")).toBeVisible({ timeout: ESPERA });
|
||||
await page.locator("#email").fill(email);
|
||||
await page.locator("#password").fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
try {
|
||||
await page.waitForURL(/\/app(\/|$)/, { timeout: 25_000 });
|
||||
return;
|
||||
|
||||
@@ -66,7 +66,7 @@ async function login(page: Page): Promise<void> {
|
||||
await page.goto("/login");
|
||||
await page.locator("#email").fill(creds.users.manager!.email);
|
||||
await page.locator("#password").fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.getByRole("button", { name: "Entrar", exact: true }).click();
|
||||
await page.waitForURL(/\/app/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user