mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
feat(marca): favicon proprio, barra do navegador sem hex duplicado, e-mails de auth com a marca
FAVICON. /favicon.ico devolvia 404 — para nos e para o revendedor. A aba nao
tinha marca nenhuma. Agora app/icon.tsx gera em runtime com next/og (zero
dependencia nova, force-dynamic), e letraDoIcone pula emoji, aceita acento e
devolve null em vez de cair na NOSSA inicial. /^\/icon$/ entra em PUBLIC_PATHS.
Sem apple-icon: o plano pedia a regex, mas allowlist para rota inexistente e
config morta.
BARRA DO NAVEGADOR — o defeito nao era o que a medicao anterior dizia. themeColor
NAO e defeito de whitelabel: cssDaMarca emite so --color-brand e a familia do
accent, NUNCA --color-bg, entao #faf9f6/#161510 sao a cor certa para qualquer
marca, e generateViewport() lendo o banco seria uma leitura por request para
devolver sempre o mesmo valor. O defeito real era DUPLICACAO: os dois hexes
viviam em app/layout.tsx, regua-do-produto.ts e globals.css sem nada
sincronizando. Agora sai da regua.
Provado por comportamento, nao por leitura: com app_name='Vendas Turbo' e
accent_hex='#f2c94c' gravados, o icone virou V sobre #6e5c28 (o accent DERIVADO,
nao a semente crua) e o titulo mudou — E o theme-color NAO mudou. E a correcao
acima, medida na tela.
E-MAILS DE AUTH. Os templates tinham a marca literal e NENHUM script os subia:
o primeiro e-mail que o cliente do revendedor recebe dizia o nome do nosso
produto. Agora hostgator-setup-kit/marca-emails.sh sobe pela Management API,
chamado pelo install.sh (sempre) e pelo update.sh (so com token). Custo para o
comprador: ZERO passo novo — com token sobe e confere; sem token, imprime o
passo manual e sai 0.
A MEDICAO QUE DESTRAVOU A DECISAO: PATCH /v1/projects/{ref}/config/auth com
mailer_templates_* e aceito e PERSISTE sem SMTP customizado. Rig num projeto
descartavel, com estado capturado antes e restaurado depois, releitura por GET
byte a byte.
E o achado que so aparece quando se mede: PROJETO PAUSADO responde 400 "Project
is paused." — modo de falha real que um script confiando no 2xx reportaria como
sucesso. Por isso o script RELE o que gravou antes de dizer que deu certo.
--render-em DIR existe porque sem ela eu estaria INTRODUZINDO um defeito:
docs/deploy-selfhost manda o GoTrue self-hosted apontar para supabase/templates/,
que agora carrega placeholder — o cliente receberia __APP_NAME__ literal.
RISCO DO TEMPLATE PADRAO, medido no mecanismo: o verificador PKCE herda as
cookieOptions da sessao, isto e sameSite:"strict", entao clique de webmail e
cross-site e o cookie nao viaja. A primeira versao da tela dizia "abra no mesmo
navegador" e foi REFEITA: cliente de e-mail nativo abre sem iniciador e o cookie
PODE ir — a tela aponta a configuracao como conserto em vez de prometer um
contorno que talvez nao funcione.
A PROSA DA 0155 corrigida: a rede de rollback pelo .env cobre NOME e LOGO; para
COR ela e vazia POR CONSTRUCAO, porque APP_ACCENT_HEX nasceu neste epico e
nenhuma versao anterior pinta accent. So comentario — a migration nao muda.
Sabotagem, previsto vs medido: 1/1, 2/2, 1/1, 2/2, (3+2)/(3+2), 1/1, 1/1, 2/2.
Oito sabotagens, zero divergencia. Restauracao por cp do backup (nunca git
checkout, que levaria trabalho nao commitado), com diff -q confirmando.
typecheck 0 · lint 0 · lint:channels 0 · test:unit 389 files / 4426 passed ·
test:shell 265 ✓ / 0 ✗ · build 0 com /icon dinamico.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TCiffR3ugjQbxsfceQE2GB
This commit is contained in:
co-authored by
Claude Opus 5
parent
a6acb9f1ca
commit
71616d783e
@@ -34,6 +34,17 @@ export default async function LoginPage({
|
||||
cadastro.
|
||||
</div>
|
||||
)}
|
||||
{error === "template_padrao" && (
|
||||
<div
|
||||
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
Este link veio do modelo de e-mail padrão do Supabase, que não fecha o
|
||||
acesso nesta instalação — pedir outro link não resolve. Quem administra
|
||||
o sistema precisa configurar os e-mails de acesso (
|
||||
<code>marca-emails.sh</code>, no kit de instalação).
|
||||
</div>
|
||||
)}
|
||||
{error === "provisionamento" && (
|
||||
<div
|
||||
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
|
||||
@@ -13,16 +13,40 @@ import { env } from "@/lib/env";
|
||||
* redefinição de senha. Dois formatos de link chegam aqui, dependendo de como
|
||||
* o projeto Supabase está configurado:
|
||||
*
|
||||
* - `token_hash` + `type`: template de e-mail customizado (supabase/templates/)
|
||||
* linkando direto pro app — exige SMTP customizado configurado no painel
|
||||
* (sem isso o Supabase não deixa editar o corpo do e-mail).
|
||||
* - `code` (PKCE): template PADRÃO do Supabase (nenhum SMTP customizado
|
||||
* configurado — caso mais comum em instalação fresca). O e-mail linka pro
|
||||
* - `token_hash` + `type`: template de e-mail customizado (supabase/templates/,
|
||||
* subidos por `hostgator-setup-kit/marca-emails.sh`) linkando direto pro app.
|
||||
* NÃO exige SMTP customizado — a versão anterior deste comentário afirmava
|
||||
* que sim ("sem isso o Supabase não deixa editar o corpo do e-mail") e isso
|
||||
* foi MEDIDO como falso em 2026-08-14: `GET /v1/projects/{ref}/config/auth`
|
||||
* do projeto de produção devolve `smtp_host: null` COM os templates
|
||||
* customizados gravados, e um `PATCH` de `mailer_templates_*` num projeto
|
||||
* sem SMTP responde 200 e persiste byte a byte (conferido relendo com GET).
|
||||
* O que exige SMTP próprio é o VOLUME de envio, não o corpo do e-mail.
|
||||
* - `code` (PKCE): template PADRÃO do Supabase (o de quem nunca configurou os
|
||||
* templates — caso mais comum em instalação fresca). O e-mail linka pro
|
||||
* `/auth/v1/verify` do próprio GoTrue, que valida e SÓ ENTÃO redireciona pra
|
||||
* cá com o code; não inclui `type`, por isso requestPasswordReset.ts e
|
||||
* signUp.ts anexam `?type=` no redirectTo/emailRedirectTo — é o único jeito
|
||||
* desse dado sobreviver ao hop pelo GoTrue nesse formato.
|
||||
*
|
||||
* ⚠️ O formato `code` NÃO FECHA nesta instalação, e o motivo é estrutural.
|
||||
* `@supabase/ssr` força `flowType: "pkce"` (createServerClient.js:33) e grava
|
||||
* o verificador num cookie (`<storageKey>-code-verifier`, cookies.js:18) com
|
||||
* as MESMAS `cookieOptions` da sessão (cookies.js:227,232) — isto é, com o
|
||||
* `sameSite: "strict"` de `lib/supabase/server.ts:35`. Clique de link vindo
|
||||
* de webmail é navegação CROSS-SITE: o navegador não manda cookie Strict, o
|
||||
* verificador não chega, e `exchangeCodeForSession` falha. O formato
|
||||
* `token_hash` não depende de cookie nenhum.
|
||||
*
|
||||
* (O que NÃO está medido: um cliente de e-mail nativo abre o link sem
|
||||
* iniciador, e nesse caso o navegador PODE mandar o cookie Strict. Por isso a
|
||||
* mensagem da tela aponta a configuração como conserto, e não promete que
|
||||
* "abrir noutro lugar" funciona.)
|
||||
*
|
||||
* É por isso que a recusa dos dois ramos não pode ter a mesma mensagem:
|
||||
* "link inválido ou expirado" manda o operador caçar TTL e relógio quando o
|
||||
* problema é que os templates nunca foram configurados.
|
||||
*
|
||||
* - type=signup → provisiona o tenant (org + membership admin) e entra no
|
||||
* onboarding. Provisionamento é idempotente (link clicado 2x).
|
||||
* - type=recovery → sessão de recovery estabelecida; segue para /login/reset
|
||||
@@ -48,6 +72,11 @@ export async function GET(request: NextRequest) {
|
||||
return redirectTo("/login?error=link_invalido");
|
||||
}
|
||||
|
||||
// Qual dos dois formatos chegou é um FATO observável, não inferência: o
|
||||
// `code` só existe no link que o template PADRÃO do Supabase monta. Guardar
|
||||
// isso antes da chamada é o que permite explicar a recusa depois.
|
||||
const viaTokenHash = Boolean(tokenHash && type);
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data, error } =
|
||||
tokenHash && type
|
||||
@@ -57,10 +86,17 @@ export async function GET(request: NextRequest) {
|
||||
if (error || !data.user) {
|
||||
await audit({
|
||||
action: "auth.email_link_rejected",
|
||||
metadata: { type, reason: error?.message ?? "no_user" },
|
||||
// `formato` é o campo que faltava: sem ele os dois modos de falha
|
||||
// chegavam ao audit log indistinguíveis, e a triagem de "o link não
|
||||
// funciona" começava do zero toda vez.
|
||||
metadata: { type, formato: viaTokenHash ? "token_hash" : "code", reason: error?.message ?? "no_user" },
|
||||
requestId,
|
||||
});
|
||||
return redirectTo("/login?error=link_invalido");
|
||||
// Dois códigos porque são duas causas e dois consertos. `link_invalido`
|
||||
// continua sendo "peça outro link". `template_padrao` diz o que a tela
|
||||
// antes escondia: o link veio do modelo padrão, pedir outro não adianta, e
|
||||
// o conserto é configurar os templates (hostgator-setup-kit/marca-emails.sh).
|
||||
return redirectTo(viaTokenHash ? "/login?error=link_invalido" : "/login?error=template_padrao");
|
||||
}
|
||||
|
||||
if (type === "recovery") {
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
import { letraDoIcone } from "@/lib/branding/icone";
|
||||
import { marcaDaSaida } from "@/lib/branding/saida";
|
||||
|
||||
/**
|
||||
* O ícone da aba, DESENHADO em runtime com a marca da instalação.
|
||||
*
|
||||
* ─── O que existia antes: nada ──────────────────────────────────────────────
|
||||
*
|
||||
* Zero `app/icon.*`, zero `app/favicon.ico`, zero `public/favicon*` (medido:
|
||||
* `public/` tem dois arquivos, `.gitkeep` e `llms.txt`). O navegador pedia
|
||||
* `/favicon.ico` por conta própria e recebia 404 — em produção, 19.435 bytes,
|
||||
* porque o 404 é a `app/not-found.tsx` INTEIRA servida para um pedido de
|
||||
* ícone. Na prática: aba sem marca nenhuma, para nós e para todo revendedor.
|
||||
*
|
||||
* ─── Por que GERADO, e não um arquivo em `public/` ──────────────────────────
|
||||
*
|
||||
* `Dockerfile:75-79` copia `public/` para a imagem final, e a imagem é UMA SÓ
|
||||
* para todas as marcas — a mesma tag do GHCR que cada clone puxa. Um
|
||||
* `favicon.ico` estático resolveria o 404 e entregaria a NOSSA marca na aba de
|
||||
* todo revendedor, que é o mesmo modo de falha que `lib/branding.ts:12-16`
|
||||
* documenta para `NEXT_PUBLIC_*`: verde em dev, verde no CI, verde na Vercel, e
|
||||
* errado exatamente na VPS de quem a feature existe para servir.
|
||||
*
|
||||
* ─── Cor + inicial, NUNCA o `logo_url` ──────────────────────────────────────
|
||||
*
|
||||
* `platform_branding.logo_url` é `text` livre, sem CHECK de host
|
||||
* (`supabase/baseline.sql:11832-11848`). Buscá-la aqui seria uma requisição de
|
||||
* saída disparada pelo `<head>` de TODA página, com a URL vinda de um campo que
|
||||
* o operador digita — SSRF com gatilho em cada page load. Derivar o ícone de
|
||||
* cor + inicial não toca a rede: o accent vem do mesmo resolvedor que pinta os
|
||||
* e-mails (`marcaDaSaida`) e a fonte (`Geist-Regular.ttf`) vem embutida no
|
||||
* `@vercel/og` que o Next já traz — nenhuma dependência nova, nenhum download.
|
||||
*
|
||||
* ─── `force-dynamic` não é zelo ─────────────────────────────────────────────
|
||||
*
|
||||
* O loader de metadata NÃO injeta `force-static` na variante gerada por código
|
||||
* (`next-metadata-route-loader.js`, `getSingleImageRouteCode`), mas também não
|
||||
* a torna dinâmica sozinha — sem esta linha o `next build` congelaria o ícone
|
||||
* dentro da imagem pré-buildada, com a marca de quem buildou. E o defeito seria
|
||||
* invisível em dev, em teste e na Vercel: só apareceria na VPS do revendedor,
|
||||
* que é o único lugar onde a marca é outra. O loader re-exporta todo named
|
||||
* export do arquivo do usuário (`:43`), então declarar aqui basta.
|
||||
*
|
||||
* ─── Custo ──────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Um `ImageResponse` por requisição a `/icon`. O `Cache-Control` abaixo é o que
|
||||
* mantém isso em uma renderização por minuto por navegador em vez de uma por
|
||||
* navegação. A leitura da marca é a MESMA que o `generateMetadata` do layout já
|
||||
* faz, memoizada por 30s (`lib/branding/instalacao.ts:209`) — nenhuma consulta
|
||||
* a mais no banco.
|
||||
*
|
||||
* ⚠️ `/icon` precisa estar em `PUBLIC_PATHS` (`lib/auth/public-paths.ts`): o
|
||||
* matcher do `proxy.ts:128` só dispensa caminho COM extensão, e `/icon` não tem
|
||||
* — sem a entrada, o ícone responde 307 para `/login` a quem ainda não entrou,
|
||||
* que é exatamente a primeira tela que um comprador vê.
|
||||
*/
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** 64 e não 32: a aba pede 16-32 CSS px, e em tela retina isso são 32-64 reais. */
|
||||
export const size = { width: 64, height: 64 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
export default async function Icon() {
|
||||
const marca = await marcaDaSaida(null);
|
||||
const letra = letraDoIcone(marca.nome);
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: marca.accent,
|
||||
color: marca.accentFg,
|
||||
// 62% da altura: a caixa maiúscula do Geist ocupa ~72% do em, então
|
||||
// a letra fica com respiro sem virar um selo minúsculo no meio.
|
||||
fontSize: Math.round(size.height * 0.62),
|
||||
// O ladrilho é quadrado e cheio: o navegador já arredonda o favicon
|
||||
// no chrome dele, e arredondar aqui também produz canto duplo.
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
{letra ?? ""}
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
headers: {
|
||||
// 60s é deliberado, e o par com o TTL da marca: o operador que troca a
|
||||
// cor em `/admin/marca` vê a aba acompanhar dentro de um minuto. Um
|
||||
// `immutable` de um ano tornaria a tela de marca uma promessa que o
|
||||
// ícone não cumpre; `no-store` faria o satori rodar a cada navegação.
|
||||
"cache-control": "public, max-age=60, stale-while-revalidate=600",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
+20
-6
@@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
|
||||
import { Atkinson_Hyperlegible, IBM_Plex_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { Toaster } from "sonner";
|
||||
import { coresDaBarraDoNavegador } from "@/lib/branding/barra-do-navegador";
|
||||
import { cssDaMarca } from "@/lib/branding/css";
|
||||
import {
|
||||
marcaDaInstalacao,
|
||||
@@ -96,14 +97,23 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
"multi-tenant",
|
||||
],
|
||||
robots: { index: false, follow: false },
|
||||
// Sem esta linha o navegador pede `/favicon.ico`, que não existe: medido em
|
||||
// produção, o 404 é a `app/not-found.tsx` INTEIRA (19.435 bytes de HTML)
|
||||
// servida para um pedido de ícone, em toda navegação sem cache. Declarar
|
||||
// `/icon` faz o pedido ir para `app/icon.tsx`, que desenha a marca da
|
||||
// instalação em runtime — ver o cabeçalho daquele arquivo para por que ele
|
||||
// não pode ser um arquivo estático em `public/`.
|
||||
icons: { icon: "/icon" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A cor da barra do navegador sai da RÉGUA, não de dois hexes redigitados aqui.
|
||||
* O porquê — inclusive por que isto NÃO deve virar `generateViewport()` lendo o
|
||||
* banco — está no cabeçalho de `lib/branding/barra-do-navegador.ts`.
|
||||
*/
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#faf9f6" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#161510" },
|
||||
],
|
||||
themeColor: coresDaBarraDoNavegador(REGUA_DO_PRODUTO),
|
||||
};
|
||||
|
||||
// Inline FOUC-prevention. Conteúdo é string literal estática (zero input do usuário),
|
||||
@@ -125,8 +135,12 @@ const motivosRegistrados = new Set<string>();
|
||||
* trocar. Server Component de propósito: a leitura é do BANCO e do `.env` em
|
||||
* runtime (a imagem self-host é pré-buildada; ver `lib/branding.ts`), e enviar
|
||||
* isso pelo cliente reintroduziria justamente o flash. Desde a migration 0155 a
|
||||
* fonte primária é `platform_branding`; o `.env` continua embaixo como semente e
|
||||
* rede de segurança de rollback (ver `lib/branding/instalacao.ts`).
|
||||
* fonte primária é `platform_branding`; o `.env` continua embaixo como semente —
|
||||
* e, para NOME e LOGO, como rede de rollback (ver `lib/branding/instalacao.ts`).
|
||||
* Para COR não existe rede: `APP_ACCENT_HEX` nasceu no mesmo épico que a tabela,
|
||||
* então nenhuma versão velha o bastante para desconhecê-la pinta accent, e o
|
||||
* `install.sh` não grava a chave (medido: 0 ocorrências, contra 7 de APP_NAME).
|
||||
* A correção está no cabeçalho da migration 0155.
|
||||
*
|
||||
* Os motivos NÃO morrem aqui: enquanto a tela de marca não existe (fase
|
||||
* seguinte, `/app/settings/tenant/branding`), o log estruturado é por onde o
|
||||
|
||||
@@ -107,7 +107,20 @@ O app tem signup self-service (`/signup`) e recuperação de senha
|
||||
(`/login/forgot`). Os dois dependem do e-mail transacional do Supabase Auth
|
||||
chegando com link para `https://SEU_DOMINIO/auth/confirm`.
|
||||
|
||||
**Supabase hospedado (recomendado):** no Dashboard →
|
||||
**O caminho automático (recomendado):** com um Personal Access Token exportado,
|
||||
|
||||
```bash
|
||||
export SUPABASE_ACCESS_TOKEN=sbp_... # supabase.com/dashboard/account/tokens
|
||||
bash hostgator-setup-kit/marca-emails.sh
|
||||
```
|
||||
|
||||
Ele sobe assunto e corpo dos dois e-mails **com a marca da sua instalação**
|
||||
(`APP_NAME` do `.env`), configura `Site URL` e `Redirect URLs`, e **relê o que
|
||||
gravou** para provar que a API aceitou. O `install.sh` já o chama sozinho
|
||||
quando o token está no ambiente. Sem o token, ele imprime o passo manual e sai
|
||||
sem quebrar nada.
|
||||
|
||||
**O caminho manual:** no Dashboard →
|
||||
|
||||
1. **Authentication → Sign In / Up**: habilite *Allow new users to sign up* e
|
||||
mantenha *Confirm email* ligado.
|
||||
@@ -118,13 +131,26 @@ chegando com link para `https://SEU_DOMINIO/auth/confirm`.
|
||||
|
||||
```html
|
||||
<!-- Confirm signup -->
|
||||
<a href="{{ .RedirectTo }}?token_hash={{ .TokenHash }}&type=signup">Confirmar e-mail</a>
|
||||
<a href="{{ .RedirectTo }}&token_hash={{ .TokenHash }}">Confirmar e-mail</a>
|
||||
<!-- Reset password -->
|
||||
<a href="{{ .RedirectTo }}?token_hash={{ .TokenHash }}&type=recovery">Redefinir senha</a>
|
||||
<a href="{{ .RedirectTo }}&token_hash={{ .TokenHash }}">Redefinir senha</a>
|
||||
```
|
||||
|
||||
⚠️ **`&`, nunca `?`.** O app já manda `.RedirectTo` com o `?type=` embutido
|
||||
(`app/actions/auth/signUp.ts` e `requestPasswordReset.ts`), então um `?`
|
||||
aqui produz `...?type=signup?token_hash=...`: o navegador para de reconhecer
|
||||
`token_hash` como parâmetro (ele vira parte do valor de `type`) e
|
||||
`/auth/confirm` manda o usuário para `/login?error=link_invalido` — com o
|
||||
link correto. Esta seção ensinava a forma com `?` até 2026-08-14, e o
|
||||
projeto Supabase de produção estava com ela gravada: quem seguiu a receita
|
||||
reproduziu o defeito.
|
||||
|
||||
4. **SMTP próprio** (Authentication → SMTP): o sender embutido do Supabase tem
|
||||
limite baixo (~2 e-mails/h) — configure Resend/SES/etc. para produção.
|
||||
Isto é sobre VOLUME de envio: editar o corpo do e-mail **não** exige SMTP
|
||||
próprio (medido em 2026-08-14: `PATCH /v1/projects/{ref}/config/auth` com
|
||||
`mailer_templates_*` responde 200 e persiste num projeto com
|
||||
`smtp_host: null`).
|
||||
|
||||
**GoTrue self-hosted:** equivalente por env:
|
||||
`GOTRUE_DISABLE_SIGNUP=false`, `GOTRUE_MAILER_AUTOCONFIRM=false`,
|
||||
@@ -134,6 +160,19 @@ chegando com link para `https://SEU_DOMINIO/auth/confirm`.
|
||||
`GOTRUE_MAILER_TEMPLATES_{CONFIRMATION,RECOVERY}` apontando para os templates
|
||||
de `supabase/templates/` (mesmo link `token_hash` acima).
|
||||
|
||||
⚠️ **Não aponte para os arquivos do repositório direto.** Eles são MODELOS: o
|
||||
nome da marca e a cor do botão são `__APP_NAME__` / `__ACCENT__`, e o cliente
|
||||
receberia isso literalmente. Renderize antes e aponte para o resultado:
|
||||
|
||||
```bash
|
||||
bash hostgator-setup-kit/marca-emails.sh --render-em /opt/deskcomm/emails
|
||||
# GOTRUE_MAILER_TEMPLATES_CONFIRMATION=/opt/deskcomm/emails/confirmation.html
|
||||
# GOTRUE_MAILER_TEMPLATES_RECOVERY=/opt/deskcomm/emails/recovery.html
|
||||
```
|
||||
|
||||
Num Supabase próprio não existe Management API, então este é o único caminho —
|
||||
e é preciso repetir o comando quando a marca mudar.
|
||||
|
||||
## 4. Conectar o WhatsApp
|
||||
|
||||
1. Acesse `https://SEU_DOMINIO/app` e crie sua conta/organização.
|
||||
|
||||
@@ -1551,6 +1551,20 @@ else
|
||||
c_ylw "⚠ supabase/baseline.sql não encontrado — pulei (aplique o schema manualmente)."
|
||||
fi
|
||||
|
||||
# ── 7.5 E-mails de acesso (criar conta / recuperar senha) ───────────────────
|
||||
# O e-mail de confirmação de conta é o PRIMEIRO artefato que qualquer usuário
|
||||
# recebe. Sem este passo ele chega no modelo padrão do Supabase — em inglês,
|
||||
# "Confirm Your Signup", sem marca nenhuma — numa instalação em que tudo o mais
|
||||
# já está com a marca de quem hospeda.
|
||||
#
|
||||
# Chamado SEMPRE, com ou sem token: sem `SUPABASE_ACCESS_TOKEN` o script imprime
|
||||
# o passo manual do painel e sai 0. É informação que vale mais aqui, no fim da
|
||||
# instalação, do que num documento que ninguém vai abrir.
|
||||
#
|
||||
# `|| true` como cinto de segurança: o script já promete nunca sair diferente de
|
||||
# 0, e mesmo assim a instalação não pode morrer por causa do e-mail.
|
||||
bash "$KIT_DIR/marca-emails.sh" --projeto "$PROJECT_DIR" || true
|
||||
|
||||
# ── 8. Bootstrap do 1º dono (cria no Auth + promove via psql) ───────────────
|
||||
step "Criando o primeiro admin (${OWNER_EMAIL})"
|
||||
# 1) Cria o usuário no Supabase Auth. Se já existe, a API responde 422 — ignoramos
|
||||
|
||||
Executable
+339
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Sobe os e-mails de ACESSO (criar conta e recuperar senha) com a marca da
|
||||
# instalação — assunto, corpo, cor do botão — e configura Site URL / Redirect
|
||||
# URLs, que são pré-requisito do link funcionar.
|
||||
#
|
||||
# bash marca-emails.sh # lê ../.env e sobe
|
||||
# bash marca-emails.sh --render-em /tmp/x # só renderiza, não sobe nada
|
||||
#
|
||||
# ── POR QUE ISTO É UM SCRIPT, E NÃO UMA FUNÇÃO DO APP ───────────────────────
|
||||
#
|
||||
# Estes dois e-mails são renderizados pelo GoTrue, um TERCEIRO PROCESSO que
|
||||
# roda na infraestrutura do Supabase. Nenhum TypeScript nosso executa ali: não
|
||||
# existe `marcaDaSaida()` a chamar, não existe resolvedor a plugar. O único
|
||||
# canal é texto empurrado por API. Por isso o artefato entregável não é o HTML
|
||||
# — é o mecanismo que o sobe.
|
||||
#
|
||||
# E ele importa mais que os outros e-mails: o de confirmação de conta é o
|
||||
# PRIMEIRO artefato que qualquer usuário de um revendedor recebe. Sem este
|
||||
# script, ele chega dizendo "Confirm Your Signup" em inglês, no modelo padrão
|
||||
# do Supabase, sem marca nenhuma.
|
||||
#
|
||||
# ── O QUE FOI MEDIDO (2026-08-14) ──────────────────────────────────────────
|
||||
#
|
||||
# O comentário de `app/auth/confirm/route.ts` afirmava que editar o corpo do
|
||||
# e-mail exige SMTP customizado. Medido contra a Management API real:
|
||||
#
|
||||
# PATCH /v1/projects/{ref}/config/auth {"mailer_templates_confirmation_content": …}
|
||||
# → HTTP 200 num projeto com `smtp_host: null`
|
||||
# → GET seguinte devolve o conteúdo BYTE A BYTE
|
||||
#
|
||||
# Ou seja: o corpo do e-mail é editável sem SMTP próprio. O que o SMTP próprio
|
||||
# resolve é o VOLUME (o remetente embutido do Supabase tem limite baixo).
|
||||
#
|
||||
# Também medido: projeto pausado responde `400 {"message":"Project is paused."}`.
|
||||
# Por isso o passo de RELEITURA abaixo não é zelo — é a diferença entre "subiu"
|
||||
# e "a API respondeu alguma coisa". Um script que confia no 2xx reporta sucesso
|
||||
# num projeto pausado.
|
||||
#
|
||||
# ── NUNCA DERRUBA QUEM O CHAMA ─────────────────────────────────────────────
|
||||
#
|
||||
# `install.sh` e `update.sh` chamam este script. Falta de token, Supabase
|
||||
# próprio (sem Management API), projeto pausado, rede fora: tudo imprime o que
|
||||
# fazer e sai 0. Uma instalação não pode morrer porque o e-mail de boas-vindas
|
||||
# ficou em inglês.
|
||||
#
|
||||
# Dependências: as mesmas do kit (bash + curl + awk). Sem jq, sem python.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||
PROJ_DIR="$(cd "$KIT_DIR/.." && pwd)"
|
||||
API="https://api.supabase.com/v1"
|
||||
|
||||
# shellcheck source=_common.sh
|
||||
. "$KIT_DIR/_common.sh"
|
||||
|
||||
# Vazio até o fim do laço: assim `--projeto` e `--env` não dependem da ordem
|
||||
# em que forem passados.
|
||||
ENV_FILE=""
|
||||
RENDER_EM=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--render-em) RENDER_EM="${2:-}"; shift 2 || shift;;
|
||||
--env) ENV_FILE="${2:-}"; shift 2 || shift;;
|
||||
# O `install.sh` pode ter sido chamado de FORA do repositório (ele clona e
|
||||
# entra), e aí `KIT_DIR/..` não é a raiz do projeto — nem o `.env` nem
|
||||
# `supabase/templates/` estariam lá. Quem sabe onde o projeto está é quem
|
||||
# chama; por isso a origem é um parâmetro, não uma adivinhação.
|
||||
--projeto) PROJ_DIR="${2:-}"; shift 2 || shift;;
|
||||
-h|--help) sed -n '2,8p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0;;
|
||||
*) c_ylw "opção desconhecida: $1"; exit 0;;
|
||||
esac
|
||||
done
|
||||
[ -n "$ENV_FILE" ] || ENV_FILE="$PROJ_DIR/.env"
|
||||
|
||||
# Sai bem-sucedido depois de ensinar o passo manual. É o contrato deste script:
|
||||
# ele informa, não interrompe.
|
||||
instrua_e_saia() {
|
||||
printf '\n' >&2
|
||||
c_ylw "⚠ $1"
|
||||
printf '\n' >&2
|
||||
c_dim " Para fazer à mão, no painel do Supabase:"
|
||||
c_dim " 1. Authentication → URL Configuration"
|
||||
c_dim " Site URL: ${APP_URL:-https://SEU_DOMINIO}"
|
||||
c_dim " Redirect URLs: ${APP_URL:-https://SEU_DOMINIO}/auth/confirm"
|
||||
c_dim " 2. Authentication → Email Templates → Confirm signup / Reset password"
|
||||
c_dim " <a href=\"{{ .RedirectTo }}&token_hash={{ .TokenHash }}\">Confirmar</a>"
|
||||
c_dim " ⚠ o separador é & — um ? aqui quebra o link (vira ?type=x?token_hash=y)"
|
||||
printf '\n' >&2
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── 1. A marca, lida do .env da instalação ─────────────────────────────────
|
||||
[ -f "$ENV_FILE" ] && load_env "$ENV_FILE"
|
||||
|
||||
APP_NOME="${APP_NAME:-}"
|
||||
[ -n "${APP_NOME//[[:space:]]/}" ] || APP_NOME="DeskcommCRM"
|
||||
APP_URL="${NEXT_PUBLIC_APP_URL:-}"
|
||||
|
||||
# `#506d48` é o accent do tema claro do produto (grau 600 da rampa em
|
||||
# lib/branding/regua-do-produto.ts) — o mesmo piso que `lib/branding/saida.ts`
|
||||
# usa nos e-mails. Não é uma cor inventada aqui.
|
||||
#
|
||||
# ⚠️ LIMITE DECLARADO: a cor sai do `.env`, e o `install.sh` não grava
|
||||
# APP_ACCENT_HEX (medido: `grep -c APP_ACCENT_HEX install.sh` → 0, contra 7 de
|
||||
# APP_NAME). Na prática, quem trocar a cor pela tela `/admin/marca` NÃO vê os
|
||||
# e-mails de acesso acompanharem: eles são texto estático dentro do GoTrue e só
|
||||
# mudam quando este script roda de novo.
|
||||
#
|
||||
# Esta defasagem é INERENTE ao mecanismo, não a esta escolha de fonte: o GoTrue
|
||||
# guarda o texto do e-mail, não uma referência ao nosso resolvedor. Trocar o
|
||||
# `.env` pelo banco como origem mudaria QUAL valor velho fica gravado, não o
|
||||
# fato de ele ficar. Quem mexer aqui esperando "ler do banco resolve" precisa
|
||||
# saber disso antes de escrever a primeira linha.
|
||||
ACCENT="${APP_ACCENT_HEX:-}"
|
||||
case "$ACCENT" in
|
||||
\#[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]) ;;
|
||||
*) ACCENT="#506d48";;
|
||||
esac
|
||||
|
||||
# A frente do botão: preto ou branco, pela luminância relativa da cor de fundo.
|
||||
#
|
||||
# Isto DUPLICA a decisão de `melhorFrenteSobre` (lib/branding/contraste.ts) —
|
||||
# de propósito, porque bash não importa TypeScript e a alternativa seria
|
||||
# `#ffffff` fixo. E `#ffffff` fixo é o defeito concreto: uma marca amarela
|
||||
# colada pelo revendedor produz texto branco sobre amarelo, ilegível, no
|
||||
# PRIMEIRO e-mail que o cliente dele recebe. As duas implementações decidem a
|
||||
# mesma coisa binária; divergir só é possível num intervalo estreito em torno
|
||||
# do limiar, e nele as duas escolhas são legíveis.
|
||||
frente_sobre() {
|
||||
local hex="${1#\#}"
|
||||
# `index()` numa tabela, e NÃO `strtonum("0x..")`: strtonum é extensão do
|
||||
# gawk. O Ubuntu de VPS traz mawk como `awk` por padrão e o macOS traz o awk
|
||||
# do BSD — em nenhum dos dois strtonum existe, e o erro ("calling undefined
|
||||
# function") derrubaria a função no cliente e não aqui.
|
||||
awk -v h="$hex" '
|
||||
function hexv(c) { return index("0123456789abcdef", tolower(c)) - 1 }
|
||||
function par(s) { return hexv(substr(s,1,1))*16 + hexv(substr(s,2,1)) }
|
||||
BEGIN{
|
||||
r=par(substr(h,1,2)); g=par(substr(h,3,2)); b=par(substr(h,5,2));
|
||||
# Luminância perceptual (Rec. 601). Suficiente para escolher entre preto e
|
||||
# branco; não é o cálculo WCAG completo, e o comentário acima diz por quê.
|
||||
y=(0.299*r + 0.587*g + 0.114*b)/255;
|
||||
print (y > 0.6 ? "#171f15" : "#ffffff");
|
||||
}'
|
||||
}
|
||||
ACCENT_FG="$(frente_sobre "$ACCENT")"
|
||||
|
||||
# ── 2. Renderiza os modelos ────────────────────────────────────────────────
|
||||
MARCADOR="marca-emails $(date +%s)"
|
||||
|
||||
# Substituição LITERAL, não `sed s|…|…|`. O nome da marca é texto que o
|
||||
# operador digita: "Vendas & Cia" faria o `&` do lado direito de um `sed` valer
|
||||
# como "o trecho casado" e o e-mail sairia dizendo "Vendas __APP_NAME__ Cia".
|
||||
# `|`, `\` e `/` no nome têm famílias de estrago parecidas. `index`/`substr` não
|
||||
# interpretam caractere nenhum.
|
||||
trocar() { # trocar <chave> <valor> (texto em stdin, resultado em stdout)
|
||||
awk -v k="$1" -v v="$2" '{
|
||||
out=""; s=$0;
|
||||
while ((p=index(s,k))>0) { out = out substr(s,1,p-1) v; s = substr(s,p+length(k)) }
|
||||
print out s
|
||||
}'
|
||||
}
|
||||
|
||||
# O nome da marca entra em TEXTO HTML. Sem escape, uma marca chamada
|
||||
# `Loja <b>Top</b>` injeta markup no corpo do e-mail, e `&` cru já é HTML
|
||||
# inválido. Só o corpo é escapado — o ASSUNTO é cabeçalho de e-mail, texto
|
||||
# puro, e `&` ali apareceria literalmente na caixa de entrada.
|
||||
#
|
||||
# Feito com `trocar` (index/substr do awk) e NÃO com `${s//&/&}`: o bash
|
||||
# 5.2 passou a expandir `&` no lado direito de `${x//p/r}` para o trecho
|
||||
# CASADO, como o sed sempre fez. Medido aqui: `Loja <b>Top</b> & Cia` saía
|
||||
# `Loja <lt;b>gt;Top...` — o `&` de `<` virava o próprio `<`. Escapar com
|
||||
# `\&` consertaria no 5.2 e quebraria no bash 4 de uma VPS antiga, que é
|
||||
# exatamente o público do kit.
|
||||
escapar_html() {
|
||||
printf '%s' "$1" \
|
||||
| trocar '&' '&' \
|
||||
| trocar '<' '<' \
|
||||
| trocar '>' '>' \
|
||||
| trocar '"' '"'
|
||||
}
|
||||
|
||||
# Tira o bloco de comentário de manutenção do topo do modelo — ele fala de
|
||||
# placeholder, de `--render-em` e do porquê do `&`, tudo endereçado a quem edita
|
||||
# o repositório. Empurrar isso para dentro do e-mail de todo cliente do
|
||||
# revendedor seria vazar nota interna (e o próprio texto do comentário sairia
|
||||
# com os placeholders já substituídos, o que confunde ainda mais).
|
||||
sem_nota_interna() {
|
||||
awk 'BEGIN{ pulando=0 }
|
||||
NR==1 && $0 ~ /^[[:space:]]*<!--/ { pulando=1 }
|
||||
pulando==1 { if ($0 ~ /-->/) { pulando=0 }; next }
|
||||
{ print }'
|
||||
}
|
||||
|
||||
renderizar() { # renderizar <arquivo>
|
||||
local arq="$1"
|
||||
[ -f "$arq" ] || return 1
|
||||
# O marcador é a PROVA de releitura (passo 6): ASCII puro e sem `<`, `>` nem
|
||||
# `&` no texto que será procurado, porque o JSON que a API devolve escapa
|
||||
# esses três (`<`, `>`, `&`) e um grep pelo original falharia
|
||||
# num conteúdo que subiu perfeitamente.
|
||||
printf '<!-- %s -->\n' "$MARCADOR"
|
||||
sem_nota_interna < "$arq" \
|
||||
| trocar "__APP_NAME__" "$(escapar_html "$APP_NOME")" \
|
||||
| trocar "__ACCENT_FG__" "$ACCENT_FG" \
|
||||
| trocar "__ACCENT__" "$ACCENT"
|
||||
}
|
||||
|
||||
HTML_CONFIRM="$(renderizar "$PROJ_DIR/supabase/templates/confirmation.html")" || instrua_e_saia "não achei supabase/templates/ — rode de dentro do repositório."
|
||||
HTML_RECOVERY="$(renderizar "$PROJ_DIR/supabase/templates/recovery.html")" || instrua_e_saia "não achei supabase/templates/ — rode de dentro do repositório."
|
||||
|
||||
if [ -n "$RENDER_EM" ]; then
|
||||
# O caminho de quem roda GoTrue self-hosted: lá não existe Management API, e
|
||||
# `GOTRUE_MAILER_TEMPLATES_*` aponta para ARQUIVO. Apontar para o modelo cru
|
||||
# entregaria `__APP_NAME__` ao cliente final.
|
||||
mkdir -p "$RENDER_EM" || instrua_e_saia "não consegui escrever em $RENDER_EM"
|
||||
printf '%s\n' "$HTML_CONFIRM" > "$RENDER_EM/confirmation.html"
|
||||
printf '%s\n' "$HTML_RECOVERY" > "$RENDER_EM/recovery.html"
|
||||
c_grn "✓ modelos renderizados em $RENDER_EM (marca: $APP_NOME, accent: $ACCENT)"
|
||||
c_dim " GoTrue self-hosted: aponte GOTRUE_MAILER_TEMPLATES_CONFIRMATION/RECOVERY para eles."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 3. Dá para subir? ──────────────────────────────────────────────────────
|
||||
[ -n "${SUPABASE_ACCESS_TOKEN:-}" ] || instrua_e_saia \
|
||||
"sem SUPABASE_ACCESS_TOKEN — não dá para configurar os e-mails de acesso sozinho.
|
||||
Pegue um token em https://supabase.com/dashboard/account/tokens, rode
|
||||
\`export SUPABASE_ACCESS_TOKEN=sbp_...\` e chame este script de novo."
|
||||
|
||||
# O ref do projeto sai da URL: https://<ref>.supabase.co. Supabase PRÓPRIO
|
||||
# (self-hosted) não tem Management API nenhuma — e nesse caso o passo é por env
|
||||
# do GoTrue, não por API.
|
||||
REF=""
|
||||
case "${NEXT_PUBLIC_SUPABASE_URL:-}" in
|
||||
https://*.supabase.co*) REF="${NEXT_PUBLIC_SUPABASE_URL#https://}"; REF="${REF%%.supabase.co*}";;
|
||||
esac
|
||||
[ -n "$REF" ] || instrua_e_saia \
|
||||
"NEXT_PUBLIC_SUPABASE_URL não é um projeto da nuvem do Supabase (${NEXT_PUBLIC_SUPABASE_URL:-vazio}).
|
||||
Num Supabase próprio, use GOTRUE_MAILER_TEMPLATES_* apontando para os
|
||||
arquivos de \`marca-emails.sh --render-em <dir>\`."
|
||||
|
||||
api() { # api <método> <caminho> [corpo]
|
||||
local method="$1" path="$2" body="${3:-}"
|
||||
if [ -n "$body" ]; then
|
||||
curl -sS -X "$method" "$API$path" \
|
||||
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" -d "$body"
|
||||
else
|
||||
curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN"
|
||||
fi
|
||||
}
|
||||
|
||||
# Mesmo extrator plano do supabase-provision.sh:123, e pelo mesmo motivo — o
|
||||
# kit promete depender só de bash+curl. O `|| true` mantém a mensagem de erro
|
||||
# ALCANÇÁVEL: campo ausente faria o grep sair 1 e, com pipefail, derrubaria a
|
||||
# atribuição antes da linha que explica o problema.
|
||||
json_str() { grep -o "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" <<<"$1" | head -1 | sed 's/.*"\([^"]*\)"$/\1/' || true; }
|
||||
|
||||
# Escapa uma string para dentro de um JSON. Não escapa acento nem `&`: JSON
|
||||
# aceita UTF-8 cru, e o que precisa sair são barra, aspas, tab e quebra de linha.
|
||||
json_escape() {
|
||||
awk 'BEGIN{ORS=""}
|
||||
{ gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); gsub(/\t/,"\\t"); gsub(/\r/,"");
|
||||
printf "%s%s", (NR>1 ? "\\n" : ""), $0 }' <<<"$1"
|
||||
}
|
||||
|
||||
step "Configurando os e-mails de acesso (marca: $APP_NOME)"
|
||||
|
||||
atual="$(api GET "/projects/$REF/config/auth")"
|
||||
case "$atual" in
|
||||
*'"message"'*) instrua_e_saia "a API do Supabase recusou a leitura: $(json_str "$atual" message)";;
|
||||
esac
|
||||
|
||||
# ── 4. Site URL e Redirect URLs: acrescentar, nunca substituir ─────────────
|
||||
#
|
||||
# Sobrescrever a lista apagaria domínios que o operador acrescentou (staging,
|
||||
# app antigo, deep link do celular) e o sintoma seria "o login parou de voltar"
|
||||
# — longe daqui e sem relação óbvia com "eu mexi nos e-mails".
|
||||
SITE_ATUAL="$(json_str "$atual" site_url)"
|
||||
ALLOW_ATUAL="$(json_str "$atual" uri_allow_list)"
|
||||
|
||||
SITE_NOVO="$SITE_ATUAL"
|
||||
if [ -n "$APP_URL" ]; then
|
||||
# Troca só quando está vazio ou no default de projeto novo do Supabase.
|
||||
# Um Site URL que o operador escolheu fica como está — com aviso.
|
||||
case "$SITE_ATUAL" in
|
||||
""|"http://localhost:3000"|"http://localhost:3000/") SITE_NOVO="$APP_URL";;
|
||||
*)
|
||||
if [ "${SITE_ATUAL%/}" != "${APP_URL%/}" ]; then
|
||||
c_ylw " • Site URL já está em $SITE_ATUAL (deixei como está; o app usa $APP_URL)"
|
||||
fi;;
|
||||
esac
|
||||
fi
|
||||
|
||||
ALLOW_NOVO="$ALLOW_ATUAL"
|
||||
if [ -n "$APP_URL" ]; then
|
||||
for entrada in "${APP_URL%/}/auth/confirm" "${APP_URL%/}/**"; do
|
||||
case ",$ALLOW_NOVO," in
|
||||
*",$entrada,"*) ;;
|
||||
*) [ -n "$ALLOW_NOVO" ] && ALLOW_NOVO="$ALLOW_NOVO,$entrada" || ALLOW_NOVO="$entrada";;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
# ── 5. Sobe ────────────────────────────────────────────────────────────────
|
||||
corpo="{
|
||||
\"mailer_subjects_confirmation\": \"$(json_escape "Confirme seu e-mail — $APP_NOME")\",
|
||||
\"mailer_subjects_recovery\": \"$(json_escape "Redefinir senha — $APP_NOME")\",
|
||||
\"mailer_templates_confirmation_content\": \"$(json_escape "$HTML_CONFIRM")\",
|
||||
\"mailer_templates_recovery_content\": \"$(json_escape "$HTML_RECOVERY")\",
|
||||
\"site_url\": \"$(json_escape "$SITE_NOVO")\",
|
||||
\"uri_allow_list\": \"$(json_escape "$ALLOW_NOVO")\"
|
||||
}"
|
||||
|
||||
resposta="$(api PATCH "/projects/$REF/config/auth" "$corpo")"
|
||||
|
||||
# ── 6. RELEITURA — a única prova que vale ──────────────────────────────────
|
||||
#
|
||||
# Não confie no 2xx. Medido: projeto pausado devolve 400 com
|
||||
# {"message":"Project is paused."} e um script que só olhasse o código de saída
|
||||
# do curl reportaria sucesso. E o modo de falha pior é o oposto — API que
|
||||
# ACEITA e IGNORA — que nenhum código de status denuncia.
|
||||
depois="$(api GET "/projects/$REF/config/auth")"
|
||||
if grep -qF "$MARCADOR" <<<"$depois"; then
|
||||
c_grn "✓ e-mails de acesso configurados e CONFERIDOS (reli o que gravei)"
|
||||
c_dim " assunto: Confirme seu e-mail — $APP_NOME"
|
||||
c_dim " botão: $ACCENT sobre texto $ACCENT_FG"
|
||||
[ "$SITE_NOVO" = "$SITE_ATUAL" ] || c_dim " site url: $SITE_NOVO"
|
||||
[ "$ALLOW_NOVO" = "$ALLOW_ATUAL" ] || c_dim " redirect: $ALLOW_NOVO"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
motivo="$(json_str "$resposta" message)"
|
||||
[ -n "$motivo" ] || motivo="$(json_str "$depois" message)"
|
||||
instrua_e_saia "os e-mails de acesso NÃO foram configurados${motivo:+ — $motivo}.
|
||||
Reli a configuração depois de gravar e o conteúdo que mandei não estava lá.
|
||||
A instalação continua funcionando; só os e-mails ficam no modelo padrão."
|
||||
@@ -804,6 +804,118 @@ case "$senha" in
|
||||
*) printf ' ✓ só alfanuméricos (não parte a connection string)\n';;
|
||||
esac
|
||||
|
||||
echo "e-mails de acesso: marca-emails.sh"
|
||||
# POR QUE ESTE BLOCO EXISTE: o e-mail de confirmação de conta é o PRIMEIRO
|
||||
# artefato que um cliente do revendedor recebe, e ele é montado por um processo
|
||||
# de TERCEIRO (o GoTrue). Nenhum teste do app o alcança — o único jeito de
|
||||
# vigiar isso é exercitar o script que empurra o texto.
|
||||
#
|
||||
# Os casos abaixo cobrem tudo o que dá para provar SEM rede: renderização,
|
||||
# escape, e as três saídas que não podem derrubar a instalação. O caminho com
|
||||
# rede (PATCH + releitura) foi medido à mão contra a Management API em
|
||||
# 2026-08-14 e está declarado no cabeçalho do script.
|
||||
ME_TMP="$(mktemp -d)"
|
||||
|
||||
# (1) Sem token, sai 0 e ENSINA o passo manual. Este é o caso comum: quem cria
|
||||
# o projeto no painel e cola as 4 credenciais nunca teve token nenhum.
|
||||
saida_me="$(SUPABASE_ACCESS_TOKEN= bash ./marca-emails.sh --env /dev/null 2>&1)"; rc_me=$?
|
||||
if [ $rc_me -ne 0 ]; then
|
||||
printf ' ✗ sem token o script saiu %s — ele NÃO pode derrubar o install.sh\n' "$rc_me"; fail=1
|
||||
else
|
||||
printf ' ✓ sem token: sai 0 (a instalação continua)\n'
|
||||
fi
|
||||
if printf '%s' "$saida_me" | grep -q 'SUPABASE_ACCESS_TOKEN'; then
|
||||
printf ' ✓ sem token: diz qual é a chave que falta\n'
|
||||
else
|
||||
printf ' ✗ sem token: a mensagem não nomeia SUPABASE_ACCESS_TOKEN\n'; fail=1
|
||||
fi
|
||||
# O passo manual tem de ensinar `&`. Foi um `?` nesta mesma receita (na doc de
|
||||
# deploy) que gravou o link quebrado no projeto de produção.
|
||||
if printf '%s' "$saida_me" | grep -q '{{ .RedirectTo }}&token_hash'; then
|
||||
printf ' ✓ sem token: o passo manual ensina o separador & (nunca ?)\n'
|
||||
else
|
||||
printf ' ✗ sem token: o passo manual não mostra o link com &\n'; fail=1
|
||||
fi
|
||||
|
||||
# (2) Supabase PRÓPRIO (self-hosted) não tem Management API: com token e tudo,
|
||||
# o certo é ensinar o caminho do GoTrue, não tentar um PATCH que não existe.
|
||||
saida_me="$(SUPABASE_ACCESS_TOKEN=sbp_de_teste NEXT_PUBLIC_SUPABASE_URL=https://supabase.meucliente.com.br \
|
||||
bash ./marca-emails.sh --env /dev/null 2>&1)"; rc_me=$?
|
||||
if [ $rc_me -eq 0 ] && printf '%s' "$saida_me" | grep -q 'GOTRUE_MAILER_TEMPLATES'; then
|
||||
printf ' ✓ Supabase próprio: sai 0 e manda para o caminho do GoTrue\n'
|
||||
else
|
||||
printf ' ✗ Supabase próprio: rc=%s, mensagem sem GOTRUE_MAILER_TEMPLATES\n' "$rc_me"; fail=1
|
||||
fi
|
||||
|
||||
# (3) VACUIDADE: os modelos no disco precisam TER o placeholder, senão o caso
|
||||
# (4) compararia a ausência de marca com a ausência de marca e passaria.
|
||||
for modelo in ../supabase/templates/confirmation.html ../supabase/templates/recovery.html; do
|
||||
if grep -q '__APP_NAME__' "$modelo"; then
|
||||
printf ' ✓ %s tem __APP_NAME__ para substituir\n' "$(basename "$modelo")"
|
||||
else
|
||||
printf ' ✗ %s NÃO tem __APP_NAME__ — a substituição abaixo não prova nada\n' "$(basename "$modelo")"; fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
# (4) Renderização com um nome HOSTIL. `&` no lado direito de um `sed` (ou de um
|
||||
# `${x//p/r}` no bash 5.2) vale como "o trecho casado" — medido: `Loja
|
||||
# <b>Top</b> & Cia` saía `Loja <lt;b>gt;Top…`. E `<b>` cru injetaria markup
|
||||
# no corpo do e-mail de todo cliente do revendedor.
|
||||
SUPABASE_ACCESS_TOKEN= APP_NAME='Loja <b>Top</b> & Cia' APP_ACCENT_HEX='#f2c94c' \
|
||||
bash ./marca-emails.sh --env /dev/null --render-em "$ME_TMP/render" >/dev/null 2>&1
|
||||
rend="$ME_TMP/render/confirmation.html"
|
||||
if [ ! -f "$rend" ]; then
|
||||
printf ' ✗ --render-em não escreveu confirmation.html\n'; fail=1
|
||||
else
|
||||
if grep -q '__APP_NAME__\|__ACCENT__\|__ACCENT_FG__' "$rend"; then
|
||||
printf ' ✗ sobrou placeholder no HTML renderizado: %s\n' "$(grep -o '__[A-Z_]*__' "$rend" | sort -u | tr '\n' ' ')"; fail=1
|
||||
else
|
||||
printf ' ✓ nenhum placeholder sobrou no HTML renderizado\n'
|
||||
fi
|
||||
if grep -qF 'Loja <b>Top</b> & Cia' "$rend"; then
|
||||
printf ' ✓ o nome da marca sai escapado (nada de markup injetado no e-mail)\n'
|
||||
else
|
||||
printf ' ✗ o nome da marca NÃO saiu escapado: %s\n' "$(grep -o 'Sua conta no [^.]*' "$rend" | head -1)"; fail=1
|
||||
fi
|
||||
# Amarelo é claro: texto branco em cima seria ilegível. É o caso exato que o
|
||||
# `#ffffff` fixo produzia — e é a marca que um revendedor cola sem avisar.
|
||||
if grep -q 'color: #171f15' "$rend"; then
|
||||
printf ' ✓ accent claro (#f2c94c) escolhe texto ESCURO\n'
|
||||
else
|
||||
printf ' ✗ accent claro não escolheu texto escuro: %s\n' "$(grep -o 'background: #f2c94c[^"]*' "$rend" | head -1)"; fail=1
|
||||
fi
|
||||
# A nota de manutenção do topo do modelo fala de placeholder e de --render-em:
|
||||
# é endereçada a quem edita o repositório, não ao cliente final.
|
||||
if grep -q 'MODELO — os' "$rend"; then
|
||||
printf ' ✗ a nota interna do modelo foi junto para dentro do e-mail\n'; fail=1
|
||||
else
|
||||
printf ' ✓ a nota interna do modelo fica fora do e-mail\n'
|
||||
fi
|
||||
fi
|
||||
|
||||
# (5) O par claro/escuro: accent escuro tem de escolher BRANCO. Sem este caso,
|
||||
# um `frente_sobre` que devolvesse sempre "#171f15" passaria no caso (4).
|
||||
SUPABASE_ACCESS_TOKEN= APP_NAME='Marca Escura' APP_ACCENT_HEX='#0b3d2e' \
|
||||
bash ./marca-emails.sh --env /dev/null --render-em "$ME_TMP/escuro" >/dev/null 2>&1
|
||||
if grep -q 'color: #ffffff' "$ME_TMP/escuro/confirmation.html" 2>/dev/null; then
|
||||
printf ' ✓ accent escuro (#0b3d2e) escolhe texto BRANCO\n'
|
||||
else
|
||||
printf ' ✗ accent escuro não escolheu texto branco\n'; fail=1
|
||||
fi
|
||||
|
||||
# (6) Hex inválido no .env não pode virar CSS quebrado no e-mail: cai no accent
|
||||
# do produto, que é o mesmo piso de lib/branding/saida.ts.
|
||||
SUPABASE_ACCESS_TOKEN= APP_NAME='Marca Torta' APP_ACCENT_HEX='verde-limão' \
|
||||
bash ./marca-emails.sh --env /dev/null --render-em "$ME_TMP/torto" >/dev/null 2>&1
|
||||
if grep -q 'background: #506d48; background: #506d48' "$ME_TMP/torto/confirmation.html" 2>/dev/null; then
|
||||
printf ' ✓ APP_ACCENT_HEX inválido cai no accent do produto\n'
|
||||
else
|
||||
printf ' ✗ APP_ACCENT_HEX inválido virou CSS inválido: %s\n' \
|
||||
"$(grep -o 'background: [^;]*;' "$ME_TMP/torto/confirmation.html" 2>/dev/null | head -2 | tr '\n' ' ')"; fail=1
|
||||
fi
|
||||
|
||||
rm -rf "$ME_TMP"
|
||||
|
||||
echo "proxy reverso: quem está com as portas 80/443"
|
||||
# A versão anterior só sabia procurar Traefik. Qualquer outro proxy — inclusive o
|
||||
# Caddy de OUTRO DeskcommCRM na mesma VPS — caía no ramo "portas livres", e a
|
||||
@@ -1139,6 +1251,10 @@ STUB
|
||||
# esconderia a trava que o dublê de crontab acima existe para não ter (era
|
||||
# `cat >/dev/null` incondicional, e num tty a suíte nunca terminava). Com
|
||||
# respostas, lê de um arquivo — é o único jeito de exercitar uma PERGUNTA.
|
||||
# `SUPABASE_ACCESS_TOKEN=` no `env`: o install.sh chama o marca-emails.sh, e um
|
||||
# token EXPORTADO no shell de quem roda a suíte entraria no cenário sem ninguém
|
||||
# pedir — o teste passaria a depender da máquina, e faria chamada de rede a
|
||||
# partir de um .env de mentira. O cenário declara o próprio ambiente.
|
||||
rodar() {
|
||||
local script="$1" flags="$2"
|
||||
printf '%s\n%s\n' "$BASE_ENV" "${3-}" > "$VPS_PROJ/.env"
|
||||
@@ -1146,9 +1262,11 @@ rodar() {
|
||||
if [ $# -ge 4 ]; then
|
||||
printf '%s' "$4" > "$VPS_RAIZ/respostas.txt"
|
||||
(cd "$VPS_PROJ" && env PATH="$VPS_RAIZ/bin:$PATH" DOCKER_LOG="$VPS_LOG" CRONTAB_SANDBOX="$CRONTAB_SANDBOX" \
|
||||
SUPABASE_ACCESS_TOKEN= \
|
||||
bash "$VPS_RAIZ/$script" $flags <"$VPS_RAIZ/respostas.txt" 2>&1 || true) | sed -E 's/\x1b\[[0-9;]*m//g'
|
||||
else
|
||||
(cd "$VPS_PROJ" && env PATH="$VPS_RAIZ/bin:$PATH" DOCKER_LOG="$VPS_LOG" CRONTAB_SANDBOX="$CRONTAB_SANDBOX" \
|
||||
SUPABASE_ACCESS_TOKEN= \
|
||||
bash "$VPS_RAIZ/$script" $flags 2>&1 || true) | sed -E 's/\x1b\[[0-9;]*m//g'
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
# à que já está aqui (é o jeito explícito de voltar no tempo)
|
||||
# --skip-backup pula o backup automático (não recomendado)
|
||||
# --to <tag> instala essa tag em vez da mais recente publicada
|
||||
source "$(dirname "$0")/_common.sh"
|
||||
# Absoluto e resolvido ANTES do `enter_project`, que faz `cd`: depois dele um
|
||||
# `dirname "$0"` relativo apontaria para o lugar errado, e o único sintoma seria
|
||||
# um script do kit "não encontrado" no meio da atualização.
|
||||
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||
source "$KIT_DIR/_common.sh"
|
||||
enter_project
|
||||
|
||||
FORCE=""; SKIP_BACKUP=""; TARGET_TAG=""
|
||||
@@ -149,6 +153,19 @@ else
|
||||
fi
|
||||
[ -n "${DESKCOMM_AGENT_REPORT:-}" ] && eval "${DESKCOMM_AGENT_REPORT_CMD}" banco
|
||||
|
||||
# ── 4.5 E-mails de acesso, para quem já estava instalado ────────────────────
|
||||
# Só COM o token no ambiente, e por isso duas coisas:
|
||||
#
|
||||
# - é assim que um clone ANTIGO recebe os e-mails com a marca dele. O
|
||||
# `install.sh` dele nunca chamou este passo (ele não existia), e nenhuma
|
||||
# atualização toca em config de auth por conta própria;
|
||||
# - sem o token, o script imprimiria o passo manual — útil UMA vez, na
|
||||
# instalação, e ruído em toda atualização a partir daí. Atualização que
|
||||
# resmunga toda vez ensina a ignorar a saída dela.
|
||||
if [ -n "${SUPABASE_ACCESS_TOKEN:-}" ]; then
|
||||
bash "$KIT_DIR/marca-emails.sh" --projeto "$PROJECT_DIR" || true
|
||||
fi
|
||||
|
||||
# ── 5. App novo ──────────────────────────────────────────────────────────────
|
||||
step "Baixando a versão nova do app e reiniciando"
|
||||
# Imagem da TAG publicada (não "latest" solto): garante que o código (checkout
|
||||
|
||||
@@ -22,6 +22,13 @@ export const PUBLIC_PATHS: RegExp[] = [
|
||||
/^\/api\/mcp(\/.*)?$/,
|
||||
/^\/_next\//,
|
||||
/^\/favicon\.ico$/,
|
||||
// O ícone da aba (`app/icon.tsx`), que o `<head>` de TODA página pede —
|
||||
// inclusive o do `/login`, antes de existir sessão. Precisa de entrada
|
||||
// própria porque o matcher do `proxy.ts:128` só dispensa caminho COM
|
||||
// extensão: `/favicon.ico` passa por ele, `/icon` não. Medido em produção
|
||||
// antes desta linha: `GET /icon` → 307 para `/login?next=%2Ficon`, enquanto
|
||||
// `/icon.png` (inexistente) devolvia 404 — a diferença é só a extensão.
|
||||
/^\/icon$/,
|
||||
/^\/team\/accept-invite\/.+$/,
|
||||
/^\/account-suspended$/,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* A cor da barra do navegador (`<meta name="theme-color">`) — LIDA da régua.
|
||||
*
|
||||
* ─── Por que NÃO é dinâmico, e por que trocar por `generateViewport()` erra ──
|
||||
*
|
||||
* A marca do operador não move o fundo da página. O serializador
|
||||
* (`lib/branding/css.ts`) emite `--color-brand` e a família do accent
|
||||
* (`--color-accent-{50..950}`, `-fg`, `-hover`, `-soft`) e mais nada — medido:
|
||||
* `grep -c color-bg lib/branding/css.ts` devolve 1, e essa única ocorrência
|
||||
* está dentro de um comentário sobre contraste. Logo `#faf9f6` / `#161510` são
|
||||
* a cor CERTA da barra para qualquer marca, e um `generateViewport()` lendo o
|
||||
* banco custaria uma leitura por requisição para devolver sempre a mesma
|
||||
* constante. Isto está escrito aqui porque a leitura apressada ("a barra tem a
|
||||
* cor do produto em todo clone!") é verdadeira e não é defeito, e sem o motivo
|
||||
* registrado alguém a "conserta" de novo.
|
||||
*
|
||||
* ─── O defeito que este módulo conserta é DUPLICAÇÃO ────────────────────────
|
||||
*
|
||||
* Os dois hexes estavam escritos em três lugares — `app/layout.tsx`,
|
||||
* `lib/branding/regua-do-produto.ts` e `app/globals.css` — sem nada os mantendo
|
||||
* em sincronia. No dia em que o fundo do produto mudasse, a barra do navegador
|
||||
* ficaria com a cor velha e nenhum gate reprovaria. Agora há uma fonte, e a
|
||||
* régua é gerada do `globals.css` por `branding-regua-do-produto.test.ts`.
|
||||
*
|
||||
* ─── Nunca lança ────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Isto roda no `viewport` do layout raiz: uma exceção aqui é 500 em TODA tela.
|
||||
* Tema sem `--color-bg` na régua simplesmente não emite entrada, e o navegador
|
||||
* usa o padrão dele — degrada em vez de derrubar. Quem reprova nesse caso é
|
||||
* `tests/unit/branding-barra-do-navegador.test.ts`, no build, e não o usuário.
|
||||
*/
|
||||
|
||||
import type { Regua, TemaDaRegua } from "./contraste";
|
||||
|
||||
/** O formato que `Viewport["themeColor"]` do Next aceita. */
|
||||
export type CorDaBarra = { readonly media: string; readonly color: string };
|
||||
|
||||
/** A chave da superfície de fundo dentro de `TemaDaRegua.base`. */
|
||||
const TOKEN_DE_FUNDO = "--color-bg";
|
||||
|
||||
function corDeFundo(tema: TemaDaRegua): string | undefined {
|
||||
return tema.base.find((t) => t.chave === TOKEN_DE_FUNDO)?.hex;
|
||||
}
|
||||
|
||||
/**
|
||||
* As duas entradas de `theme-color`, na ordem claro → escuro.
|
||||
*
|
||||
* `flatMap` e não `map`: é o que descarta o tema sem cor sem precisar de `!`
|
||||
* num campo que o tipo do Next declara obrigatório — o cast que a doutrina
|
||||
* proíbe, e que aqui esconderia justamente a régua quebrada.
|
||||
*/
|
||||
export function coresDaBarraDoNavegador(regua: Regua): CorDaBarra[] {
|
||||
return (
|
||||
[
|
||||
["(prefers-color-scheme: light)", corDeFundo(regua.claro)],
|
||||
["(prefers-color-scheme: dark)", corDeFundo(regua.escuro)],
|
||||
] as const
|
||||
).flatMap(([media, color]) => (color ? [{ media, color }] : []));
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* A letra que vai no ícone da aba — e o caso em que NÃO vai letra nenhuma.
|
||||
*
|
||||
* Mora fora de `app/icon.tsx` de propósito. O loader de metadata do Next
|
||||
* re-exporta TODO named export do arquivo do usuário para o módulo de rota
|
||||
* gerado (`next-metadata-route-loader.js:43`, exceto `default`,
|
||||
* `generateSitemaps` e `dynamicParams`), então uma função auxiliar exportada
|
||||
* de lá viraria export de rota — e export desconhecido em módulo de rota é
|
||||
* justamente o que o Next reprova no build. Aqui ela é uma função pura,
|
||||
* testável sem subir renderizador de imagem nenhum.
|
||||
*
|
||||
* ─── Por que não dá para reusar `resolveBranding().initial` direto ──────────
|
||||
*
|
||||
* `resolveBranding` (`lib/branding.ts:47-50`) usa spread em vez de `[0]` para
|
||||
* não partir code point — e por isso devolve o EMOJI quando a marca começa com
|
||||
* um ("🚀 Foguete" → "🚀"). Na sidebar isso é o comportamento certo: o browser
|
||||
* tem as fontes de emoji. No ícone não: quem desenha é o satori (`next/og`),
|
||||
* que só carrega a fonte embutida `Geist-Regular.ttf` e renderiza QUALQUER
|
||||
* glifo ausente como tofu (▯). Buscar uma fonte de emoji resolveria — e faria
|
||||
* o `<head>` de toda página depender de uma requisição de rede, que é
|
||||
* exatamente o que o ícone gerado existe para evitar.
|
||||
*
|
||||
* Então a regra é: a primeira LETRA OU DÍGITO do nome. Não havendo nenhuma
|
||||
* (marca só de emoji, ou só de pontuação), devolve `null` — e quem chama
|
||||
* desenha o ladrilho do accent SEM letra. Cair na inicial do produto seria
|
||||
* pior que não ter letra: a aba do revendedor mostraria a NOSSA letra, que é o
|
||||
* defeito de white-label que este épico inteiro existe para fechar.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A primeira letra ou dígito de `nome`, em caixa alta. `null` quando o nome não
|
||||
* tem nenhum — aí o ícone é só a cor da marca.
|
||||
*
|
||||
* `\p{L}` e `\p{N}` com a flag `u`, não `[A-Za-z0-9]`: "Ótimo CRM" tem de
|
||||
* render "Ó", e "Ácaro" tem de render "Á". Restringir ao ASCII descartaria a
|
||||
* primeira letra de boa parte dos nomes em português e devolveria a segunda.
|
||||
*/
|
||||
export function letraDoIcone(nome: string): string | null {
|
||||
for (const caractere of nome.trim()) {
|
||||
if (/\p{L}|\p{N}/u.test(caractere)) return caractere.toUpperCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -11848,7 +11848,7 @@ create table if not exists public.platform_branding (
|
||||
);
|
||||
|
||||
comment on table public.platform_branding is
|
||||
'Marca da INSTALAÇÃO (login, e-mail, 500) — linha única id=1. Semeada do .env na primeira leitura; o .env continua sendo a rede de segurança de rollback. Lida/escrita só server-side (service_role). Ver lib/branding/instalacao.ts.';
|
||||
'Marca da INSTALAÇÃO (login, e-mail, 500) — linha única id=1. Semeada do .env na primeira leitura; para NOME e LOGO o .env continua sendo a rede de segurança de rollback (o agent.sh reverte a imagem, não o banco). Para COR não há rede: APP_ACCENT_HEX nasceu junto com esta tabela e o install.sh não o grava — nenhuma versão que desconheça platform_branding pinta accent. Lida/escrita só server-side (service_role). Ver lib/branding/instalacao.ts.';
|
||||
|
||||
comment on column public.platform_branding.seeded_from_env is
|
||||
'true = os valores vieram do .env e ninguém os editou pela tela. A escrita humana zera isto, e é o que impede a semeadura de reescrever o que uma pessoa apagou de propósito.';
|
||||
|
||||
@@ -19,6 +19,27 @@
|
||||
-- `.env` intacto, a marca degrada para o valor da instalação e a tela continua
|
||||
-- sendo a do cliente.
|
||||
--
|
||||
-- ⚠️ CORREÇÃO DE 2026-08-14 — a rede cobre NOME E LOGO, não a cor.
|
||||
--
|
||||
-- O parágrafo acima foi escrito como se valesse para as três colunas. Vale para
|
||||
-- duas. `install.sh` grava `APP_NAME` e `APP_LOGO_URL` no `.env` e NÃO grava
|
||||
-- `APP_ACCENT_HEX` (medido: `grep -c APP_ACCENT_HEX hostgator-setup-kit/install.sh`
|
||||
-- → 0, contra 7 de `APP_NAME`) — a chave só existe em `.env.example`, e o `.env`
|
||||
-- é escrito com truncamento, então quem a puser à mão a perde no install
|
||||
-- seguinte.
|
||||
--
|
||||
-- E, mais fundo que isso: para COR a rede é vazia POR CONSTRUÇÃO, não por
|
||||
-- descuido. `APP_ACCENT_HEX` nasceu neste mesmo épico, junto com esta tabela.
|
||||
-- Não existe versão do app velha o bastante para desconhecer `platform_branding`
|
||||
-- e ainda assim pintar accent a partir do `.env` — quem não conhece a tabela
|
||||
-- também não conhece a variável. Gravar a cor no `.env` seria semente e proteção
|
||||
-- contra o truncamento do install, não rollback.
|
||||
--
|
||||
-- O `comment on table` do `supabase/baseline.sql` já carrega a redação
|
||||
-- corrigida. Aqui embaixo ele fica como foi aplicado: migration já aplicada não
|
||||
-- se reescreve, corrige-se por forward-fix — e nesta linha a diferença é de
|
||||
-- redação, não de comportamento.
|
||||
--
|
||||
-- ─── Singleton, e por que ele é uma linha e não uma coluna em `organizations` ─
|
||||
--
|
||||
-- DIRC responde **D — Duplicar? Não; vive aqui mesmo**. Esta é a marca da
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
<!--
|
||||
MODELO — os `__PLACEHOLDER__` são substituídos por hostgator-setup-kit/marca-emails.sh,
|
||||
que lê APP_NAME e APP_ACCENT_HEX do .env da instalação e sobe o resultado pela
|
||||
Management API do Supabase (PATCH /v1/projects/{ref}/config/auth).
|
||||
|
||||
Quem roda GoTrue self-hosted (GOTRUE_MAILER_TEMPLATES_CONFIRMATION) NÃO pode
|
||||
apontar para ESTE arquivo: ele carrega placeholder. Renderize primeiro —
|
||||
`bash hostgator-setup-kit/marca-emails.sh --render-em /caminho/` — e aponte
|
||||
para o arquivo gerado.
|
||||
|
||||
As cores do botão vêm em PAR (`background: <fallback>; background: __ACCENT__;`)
|
||||
de propósito: declaração com valor inválido é DESCARTADA pelo CSS, então o
|
||||
arquivo cru — o que o Supabase local usa em dev e no CI — renderiza com o
|
||||
accent do produto em vez de um botão transparente. É a única forma de o
|
||||
modelo ser legível antes de ser substituído.
|
||||
|
||||
`&`, nunca `?`: ver o comentário equivalente em recovery.html. `.RedirectTo`
|
||||
já chega com `?type=signup` embutido (app/actions/auth/signUp.ts).
|
||||
-->
|
||||
<html>
|
||||
<body style="font-family: sans-serif; color: #1a1a1a; max-width: 480px; margin: 0 auto; padding: 24px;">
|
||||
<body style="font-family: sans-serif; color: #1c1a16; background: #faf9f6; max-width: 480px; margin: 0 auto; padding: 24px;">
|
||||
<h2 style="font-size: 18px;">Confirme seu e-mail</h2>
|
||||
<p>Sua conta no DeskcommCRM está quase pronta. Clique no botão abaixo para confirmar seu e-mail e ativar sua conta:</p>
|
||||
<!--
|
||||
`&`, nunca `?`: ver comentário equivalente em recovery.html. `.RedirectTo`
|
||||
já chega com `?type=signup` embutido (app/actions/auth/signUp.ts).
|
||||
-->
|
||||
<p>Sua conta no __APP_NAME__ está quase pronta. Clique no botão abaixo para confirmar seu e-mail e ativar sua conta:</p>
|
||||
<p style="margin: 24px 0;">
|
||||
<a
|
||||
href="{{ .RedirectTo }}&token_hash={{ .TokenHash }}"
|
||||
style="background: #4a5d52; color: #ffffff; padding: 10px 20px; border-radius: 6px; text-decoration: none; display: inline-block;"
|
||||
style="background: #506d48; background: __ACCENT__; color: #ffffff; color: __ACCENT_FG__; padding: 10px 20px; border-radius: 6px; text-decoration: none; display: inline-block;"
|
||||
>Confirmar e-mail</a
|
||||
>
|
||||
</p>
|
||||
<p style="font-size: 12px; color: #666;">Se você não criou esta conta, ignore este e-mail.</p>
|
||||
<p style="font-size: 12px; color: #7d786c;">Se você não criou esta conta, ignore este e-mail.</p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
<!--
|
||||
MODELO — os `__PLACEHOLDER__` são substituídos por hostgator-setup-kit/marca-emails.sh.
|
||||
Ver o cabeçalho de confirmation.html para o par de cores, para o caminho do
|
||||
GoTrue self-hosted (`--render-em`) e para quem faz a substituição.
|
||||
|
||||
`&`, nunca `?`: `.RedirectTo` já chega com `?type=recovery` embutido
|
||||
(app/actions/auth/requestPasswordReset.ts) — é o que sobrevive ao hop pelo
|
||||
GoTrue quando o link chega via PKCE (`code`) em vez de token_hash.
|
||||
Usar `?` aqui duplica o separador (`...?type=recovery?token_hash=...`),
|
||||
o parser de URL do browser para de reconhecer `token_hash` como
|
||||
parâmetro (vira parte do valor de `type`), e /auth/confirm redireciona
|
||||
pra `/login?error=link_invalido` mesmo com o link correto — medido nas
|
||||
specs password-recovery.spec.ts e signup-journey.spec.ts.
|
||||
-->
|
||||
<html>
|
||||
<body style="font-family: sans-serif; color: #1a1a1a; max-width: 480px; margin: 0 auto; padding: 24px;">
|
||||
<body style="font-family: sans-serif; color: #1c1a16; background: #faf9f6; max-width: 480px; margin: 0 auto; padding: 24px;">
|
||||
<h2 style="font-size: 18px;">Redefinir senha</h2>
|
||||
<p>Recebemos um pedido para redefinir a senha da sua conta no DeskcommCRM. Clique no botão abaixo para escolher uma nova senha:</p>
|
||||
<!--
|
||||
`&`, nunca `?`: `.RedirectTo` já chega com `?type=recovery` embutido
|
||||
(app/actions/auth/requestPasswordReset.ts) — é o que sobrevive ao hop
|
||||
pelo GoTrue quando o link chega via PKCE (`code`) em vez de token_hash.
|
||||
Usar `?` aqui duplica o separador (`...?type=recovery?token_hash=...`),
|
||||
o parser de URL do browser para de reconhecer `token_hash` como
|
||||
parâmetro (vira parte do valor de `type`), e /auth/confirm redireciona
|
||||
pra `/login?error=link_invalido` mesmo com o link correto — medido nas
|
||||
specs password-recovery.spec.ts e signup-journey.spec.ts.
|
||||
-->
|
||||
<p>Recebemos um pedido para redefinir a senha da sua conta no __APP_NAME__. Clique no botão abaixo para escolher uma nova senha:</p>
|
||||
<p style="margin: 24px 0;">
|
||||
<a
|
||||
href="{{ .RedirectTo }}&token_hash={{ .TokenHash }}"
|
||||
style="background: #4a5d52; color: #ffffff; padding: 10px 20px; border-radius: 6px; text-decoration: none; display: inline-block;"
|
||||
style="background: #506d48; background: __ACCENT__; color: #ffffff; color: __ACCENT_FG__; padding: 10px 20px; border-radius: 6px; text-decoration: none; display: inline-block;"
|
||||
>Redefinir senha</a
|
||||
>
|
||||
</p>
|
||||
<p style="font-size: 12px; color: #666;">Se você não pediu a redefinição, ignore este e-mail — sua senha atual continua valendo.</p>
|
||||
<p style="font-size: 12px; color: #7d786c;">Se você não pediu a redefinição, ignore este e-mail — sua senha atual continua valendo.</p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* A aba do navegador — o único pedaço da marca que aparece em TODA tela,
|
||||
* inclusive antes de existir sessão, e o único que nenhum teste vigiava.
|
||||
*
|
||||
* Antes desta spec: `grep -rn 'toHaveTitle|page.title()' tests/e2e/` devolvia
|
||||
* vazio e nenhum teste citava favicon ou `generateMetadata`. Quem mexesse no
|
||||
* `template` do título ou no `<head>` do layout não era avisado por gate
|
||||
* nenhum — num repositório que já pagou a lição de que rótulo visível é
|
||||
* contrato.
|
||||
*
|
||||
* Tudo aqui roda DESLOGADO de propósito. É o estado do comprador que acabou de
|
||||
* instalar e abriu a primeira tela: se o ícone responder 307 para o login (era
|
||||
* o comportamento antes de `/icon` entrar em PUBLIC_PATHS), a marca dele não
|
||||
* aparece exatamente na primeira impressão. E, por não precisar de login, esta
|
||||
* spec não consome do teto de 60 logins/IP/300s que a suíte compartilha.
|
||||
*/
|
||||
test.describe("o ícone e o título carregam a marca da instalação", () => {
|
||||
test("GET /icon responde imagem para quem não entrou", async ({ request }) => {
|
||||
const res = await request.get("/icon", { maxRedirects: 0 });
|
||||
|
||||
// 307 aqui significa que o proxy interceptou: o matcher de `proxy.ts` só
|
||||
// dispensa caminho COM extensão, e `/icon` não tem nenhuma.
|
||||
expect(
|
||||
res.status(),
|
||||
"esperava a imagem; 307 significa que /icon caiu no redirect para /login",
|
||||
).toBe(200);
|
||||
expect(res.headers()["content-type"]).toMatch(/^image\//);
|
||||
|
||||
// O corpo tem de ser um PNG DE VERDADE. Sem esta asserção, um handler que
|
||||
// devolvesse 200 com corpo vazio (ou com o HTML de erro do Next) passaria.
|
||||
const corpo = await res.body();
|
||||
expect(corpo.byteLength).toBeGreaterThan(100);
|
||||
expect([...corpo.subarray(0, 4)]).toEqual([0x89, 0x50, 0x4e, 0x47]);
|
||||
});
|
||||
|
||||
test("o <head> do login declara o ícone — é o que mata o pedido a /favicon.ico", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/login");
|
||||
const href = await page
|
||||
.locator('link[rel~="icon"]')
|
||||
.first()
|
||||
.getAttribute("href");
|
||||
expect(href, "nenhum <link rel=icon> no <head>").toBeTruthy();
|
||||
// `/icon` com ou sem query de cache-busting do Next.
|
||||
expect(new URL(href ?? "", "http://x").pathname).toBe("/icon");
|
||||
});
|
||||
|
||||
test("o título da aba herda a marca resolvida, e é a MESMA que a tela mostra", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/login");
|
||||
const titulo = await page.title();
|
||||
|
||||
// `template: "%s · ${name}"` no layout raiz: a página filha declara só
|
||||
// "Entrar" e herda o sufixo. Um layout que perdesse o template deixaria o
|
||||
// título como "Entrar" pelado, e ninguém notaria.
|
||||
const casou = /^Entrar · (.+)$/.exec(titulo);
|
||||
expect(casou, `título fora do formato "Entrar · <marca>": ${titulo}`).not.toBeNull();
|
||||
|
||||
const marcaNoTitulo = casou?.[1] ?? "";
|
||||
expect(marcaNoTitulo.length).toBeGreaterThan(0);
|
||||
|
||||
// Cruza DUAS resoluções independentes: o título vem de `generateMetadata`
|
||||
// (que lê `platform_branding` no banco) e o texto sob o "Entrar" vem de
|
||||
// `branding()` (que lê o `.env`). Divergirem é defeito de verdade — foi
|
||||
// por não cruzar isso que "trocar o nome pela tela e a aba não acompanhar"
|
||||
// passou despercebido antes.
|
||||
await expect(page.getByText(marcaNoTitulo, { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* A RECUSA DE UM LINK DE E-MAIL PRECISA DIZER QUAL DAS DUAS COISAS QUEBROU.
|
||||
*
|
||||
* `/auth/confirm` aceita dois formatos de link e, até 2026-08-14, mandava os
|
||||
* dois para a MESMA tela: `/login?error=link_invalido` → "Link inválido ou
|
||||
* expirado. Peça um novo em Recuperar senha". Isso é conselho certo para um
|
||||
* caso e conselho ERRADO para o outro:
|
||||
*
|
||||
* - `token_hash` (nossos templates): o link realmente expirou ou já foi usado.
|
||||
* Pedir outro resolve.
|
||||
* - `code` (template PADRÃO do Supabase): o link chega por PKCE, o verificador
|
||||
* vive num cookie `sameSite: "strict"` (`lib/supabase/server.ts:35`, aplicado
|
||||
* a TODO cookie do cliente — `@supabase/ssr/cookies.js:227,232`), e clique
|
||||
* vindo de webmail é navegação cross-site: o cookie não viaja. Pedir outro
|
||||
* link NÃO resolve — cada novo link falha igual. O conserto é configurar os
|
||||
* templates (`hostgator-setup-kit/marca-emails.sh`).
|
||||
*
|
||||
* O sintoma enganoso mandava o operador caçar TTL e relógio do servidor. Este
|
||||
* teste prende a distinção nos DOIS sentidos: sem o segundo caso, alguém
|
||||
* "simplifica" os dois ramos de volta para uma mensagem só; sem o primeiro,
|
||||
* alguém troca tudo por `template_padrao` e passa a acusar de configuração o
|
||||
* link que de fato expirou.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { audit } from "@/lib/audit";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn() }));
|
||||
vi.mock("@/lib/audit", () => ({ audit: vi.fn(async () => undefined) }));
|
||||
vi.mock("@/lib/auth/provision", () => ({ ensureTenantForUser: vi.fn(async () => undefined) }));
|
||||
vi.mock("@/lib/env", () => ({ env: { NEXT_PUBLIC_APP_URL: "https://crm.exemplo.com.br" } }));
|
||||
|
||||
import { GET } from "@/app/auth/confirm/route";
|
||||
|
||||
type Resultado = { data: { user: unknown }; error: { message: string } | null };
|
||||
|
||||
const RECUSA: Resultado = { data: { user: null }, error: { message: "Token has expired" } };
|
||||
|
||||
function supabaseQue(resposta: Resultado) {
|
||||
const verifyOtp = vi.fn(async () => resposta);
|
||||
const exchangeCodeForSession = vi.fn(async () => resposta);
|
||||
vi.mocked(createClient).mockResolvedValue({
|
||||
auth: { verifyOtp, exchangeCodeForSession },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
return { verifyOtp, exchangeCodeForSession };
|
||||
}
|
||||
|
||||
const chamar = (query: string) =>
|
||||
GET(new NextRequest(`https://crm.exemplo.com.br/auth/confirm${query}`));
|
||||
|
||||
/** O `Location` do redirect, sem o host. */
|
||||
async function destino(query: string): Promise<string> {
|
||||
const res = await chamar(query);
|
||||
return new URL(res.headers.get("location") ?? "").search;
|
||||
}
|
||||
|
||||
describe("/auth/confirm nomeia a causa da recusa", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("token_hash que falhou continua sendo `link_invalido` — pedir outro resolve", () => {
|
||||
supabaseQue(RECUSA);
|
||||
return expect(destino("?token_hash=abc&type=recovery")).resolves.toBe("?error=link_invalido");
|
||||
});
|
||||
|
||||
it("code que falhou vira `template_padrao` — pedir outro NÃO resolve", async () => {
|
||||
supabaseQue(RECUSA);
|
||||
expect(await destino("?code=pkce_abc")).toBe("?error=template_padrao");
|
||||
});
|
||||
|
||||
it("link sem token nenhum continua `link_invalido` e não chama o Supabase", async () => {
|
||||
const { verifyOtp, exchangeCodeForSession } = supabaseQue(RECUSA);
|
||||
expect(await destino("")).toBe("?error=link_invalido");
|
||||
expect(verifyOtp).not.toHaveBeenCalled();
|
||||
expect(exchangeCodeForSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("o audit registra QUAL formato falhou — sem isso a triagem começa do zero", async () => {
|
||||
supabaseQue(RECUSA);
|
||||
await chamar("?code=pkce_abc");
|
||||
expect(vi.mocked(audit)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "auth.email_link_rejected",
|
||||
metadata: expect.objectContaining({ formato: "code" }),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.clearAllMocks();
|
||||
supabaseQue(RECUSA);
|
||||
await chamar("?token_hash=abc&type=signup");
|
||||
expect(vi.mocked(audit)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "auth.email_link_rejected",
|
||||
metadata: expect.objectContaining({ formato: "token_hash" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("a tela do login tem texto para o código novo — senão a recusa fica muda", () => {
|
||||
// Sem este caso, `?error=template_padrao` chegaria a uma página que só
|
||||
// conhece `link_invalido` e `provisionamento`: nenhum aviso renderiza, e o
|
||||
// usuário vê a tela de login limpa, como se nada tivesse acontecido.
|
||||
// É o modo de falha mais silencioso possível — pior que a mensagem errada.
|
||||
const fonte = fs.readFileSync(
|
||||
path.join(process.cwd(), "app/(public)/login/page.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(fonte).toContain('error === "template_padrao"');
|
||||
expect(fonte).toContain("marca-emails.sh");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
coresDaBarraDoNavegador,
|
||||
type CorDaBarra,
|
||||
} from "@/lib/branding/barra-do-navegador";
|
||||
import type { Regua, TemaDaRegua } from "@/lib/branding/contraste";
|
||||
import { REGUA_DO_PRODUTO } from "@/lib/branding/regua-do-produto";
|
||||
|
||||
const RAIZ = process.cwd();
|
||||
|
||||
/**
|
||||
* A barra do navegador é a única cor do produto que aparece ANTES de o HTML
|
||||
* pintar — e a que nenhuma tela mostra, então ninguém a olha. Estes casos
|
||||
* existem por dois motivos distintos:
|
||||
*
|
||||
* 1. os dois hexes viviam em TRÊS arquivos sem nada os sincronizando. Este
|
||||
* teste é o que passa a reprovar a quarta cópia;
|
||||
* 2. a leitura errada ("a barra fica com a cor do produto em todo clone — é
|
||||
* defeito de white-label!") é convidativa e FALSA: `cssDaMarca` nunca emite
|
||||
* `--color-bg`. Sem um caso que registre isso, alguém troca `viewport` por
|
||||
* `generateViewport()` e paga uma leitura de banco por requisição para
|
||||
* devolver sempre a mesma constante.
|
||||
*/
|
||||
describe("cor da barra do navegador", () => {
|
||||
const bg = (tema: TemaDaRegua) =>
|
||||
tema.base.find((t) => t.chave === "--color-bg")?.hex;
|
||||
|
||||
it("as duas entradas saem da régua, não de literal digitado", () => {
|
||||
const cores = coresDaBarraDoNavegador(REGUA_DO_PRODUTO);
|
||||
expect(cores).toEqual<CorDaBarra[]>([
|
||||
{
|
||||
media: "(prefers-color-scheme: light)",
|
||||
// Sem `!`: se a régua perder o token, `toEqual` compara com `undefined`
|
||||
// e o caso reprova — que é o desfecho certo.
|
||||
color: bg(REGUA_DO_PRODUTO.claro) as string,
|
||||
},
|
||||
{
|
||||
media: "(prefers-color-scheme: dark)",
|
||||
color: bg(REGUA_DO_PRODUTO.escuro) as string,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("a régua de hoje tem os dois fundos — senão o caso acima seria vazio", () => {
|
||||
// Guarda de vacuidade: sem ela, uma régua sem `--color-bg` faria o teste
|
||||
// acima comparar `[]` com `[]` e passar como se estivesse vigiando.
|
||||
expect(bg(REGUA_DO_PRODUTO.claro)).toMatch(/^#[0-9a-f]{6}$/);
|
||||
expect(bg(REGUA_DO_PRODUTO.escuro)).toMatch(/^#[0-9a-f]{6}$/);
|
||||
expect(bg(REGUA_DO_PRODUTO.claro)).not.toBe(bg(REGUA_DO_PRODUTO.escuro));
|
||||
});
|
||||
|
||||
it("tema sem `--color-bg` some da lista em vez de derrubar toda tela", () => {
|
||||
// `viewport` roda no layout raiz: lançar aqui é 500 em TODA página, para
|
||||
// todo mundo, por causa de um token de cor. Degradar é a escolha certa —
|
||||
// e é o caso acima que impede que a degradação passe despercebida.
|
||||
const semFundo: Regua = {
|
||||
...REGUA_DO_PRODUTO,
|
||||
escuro: { ...REGUA_DO_PRODUTO.escuro, base: [] },
|
||||
};
|
||||
const cores = coresDaBarraDoNavegador(semFundo);
|
||||
expect(cores).toHaveLength(1);
|
||||
expect(cores[0]?.media).toBe("(prefers-color-scheme: light)");
|
||||
});
|
||||
|
||||
it("o layout não redigita os hexes de fundo — a quarta cópia reprova aqui", () => {
|
||||
// O teste acima continuaria verde se alguém escrevesse
|
||||
// `themeColor: [{ media: "…", color: "#faf9f6" }]` de volta no layout: o
|
||||
// valor seria o mesmo. O que fecha esse buraco é olhar o TEXTO do arquivo.
|
||||
const layout = fs.readFileSync(path.join(RAIZ, "app/layout.tsx"), "utf8");
|
||||
for (const tema of [REGUA_DO_PRODUTO.claro, REGUA_DO_PRODUTO.escuro]) {
|
||||
const hex = bg(tema);
|
||||
expect(hex).toBeTruthy();
|
||||
expect(
|
||||
layout.includes(hex as string),
|
||||
`app/layout.tsx voltou a escrever ${hex} à mão. A cor da barra tem UMA ` +
|
||||
`fonte: REGUA_DO_PRODUTO, via coresDaBarraDoNavegador().`,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isPublicPath, PUBLIC_PATHS } from "@/lib/auth/public-paths";
|
||||
import { letraDoIcone } from "@/lib/branding/icone";
|
||||
|
||||
const RAIZ = process.cwd();
|
||||
|
||||
describe("letra do ícone da aba", () => {
|
||||
it("usa a primeira letra do nome, em caixa alta", () => {
|
||||
expect(letraDoIcone("Vendas Turbo")).toBe("V");
|
||||
expect(letraDoIcone(" acme crm ")).toBe("A");
|
||||
});
|
||||
|
||||
it("aceita acento — restringir ao ASCII devolveria a segunda letra", () => {
|
||||
// "Ótimo CRM" com `[A-Za-z]` renderizaria "T". Em português isso não é
|
||||
// borda: é uma fatia grande dos nomes possíveis.
|
||||
expect(letraDoIcone("Ótimo CRM")).toBe("Ó");
|
||||
expect(letraDoIcone("Ácaro")).toBe("Á");
|
||||
});
|
||||
|
||||
it("pula o emoji e pega a primeira letra de verdade", () => {
|
||||
// `resolveBranding().initial` devolveria "🚀" — certo na sidebar, onde o
|
||||
// browser tem fonte de emoji; errado no ícone, que o satori desenha com a
|
||||
// única fonte embutida e renderizaria como tofu (▯).
|
||||
expect(letraDoIcone("🚀 Foguete")).toBe("F");
|
||||
expect(letraDoIcone("★ Estrela")).toBe("E");
|
||||
});
|
||||
|
||||
it("devolve null quando não há letra nenhuma — e NÃO a inicial do produto", () => {
|
||||
// Cair em "D" aqui poria a NOSSA letra na aba do revendedor, que é
|
||||
// exatamente o vazamento de marca que o épico fecha. Sem letra, o ícone é
|
||||
// só o ladrilho da cor dele.
|
||||
expect(letraDoIcone("🚀")).toBeNull();
|
||||
expect(letraDoIcone(" ")).toBeNull();
|
||||
expect(letraDoIcone("···")).toBeNull();
|
||||
});
|
||||
|
||||
it("dígito conta como letra — marca que começa com número tem ícone", () => {
|
||||
expect(letraDoIcone("360 Vendas")).toBe("3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("o ícone carrega para quem NÃO entrou", () => {
|
||||
it("/icon é caminho público", () => {
|
||||
// Sem esta entrada o `<head>` do /login pede /icon, o proxy responde 307
|
||||
// para /login?next=%2Ficon, e a aba fica sem marca justamente na primeira
|
||||
// tela que um comprador vê. O matcher do proxy só dispensa caminho COM
|
||||
// extensão — medido: /icon.png devolvia 404 (passou direto) e /icon, 307.
|
||||
expect(isPublicPath("/icon")).toBe(true);
|
||||
});
|
||||
|
||||
it("a entrada é ancorada — não abre /icon-secreto nem /admin/icon", () => {
|
||||
// Regex de caminho público sem âncora é como allowlist vira buraco.
|
||||
expect(isPublicPath("/iconografia")).toBe(false);
|
||||
expect(isPublicPath("/admin/icon")).toBe(false);
|
||||
expect(isPublicPath("/icon/../app")).toBe(false);
|
||||
});
|
||||
|
||||
it("a rota existe no disco com o nome que a regex espera", () => {
|
||||
// Guarda de vacuidade: a asserção acima passaria com a regex presente e o
|
||||
// arquivo ausente — allowlist para uma rota que não existe é config morta,
|
||||
// e o favicon continuaria em 404 com o teste verde.
|
||||
expect(fs.existsSync(path.join(RAIZ, "app/icon.tsx"))).toBe(true);
|
||||
// Na RAIZ de `app/`, nunca dentro de route group: `getMetadataRouteSuffix`
|
||||
// acrescenta um hash djb2 de 6 caracteres quando algum segmento pai é
|
||||
// group ou parallel route, e `app/(public)/icon.tsx` seria servido em
|
||||
// `/icon-<hash>` — a regex acima erraria em silêncio.
|
||||
expect(PUBLIC_PATHS.some((re) => re.source === String.raw`^\/icon$`)).toBe(true);
|
||||
});
|
||||
|
||||
it("o ícone é gerado em runtime, nunca congelado no build", () => {
|
||||
// Sem `force-dynamic` o `next build` resolve o ícone UMA vez e a marca de
|
||||
// quem buildou vai dentro da imagem — que é uma só para todos os clones.
|
||||
// O defeito é invisível em dev, em teste e na Vercel: só aparece na VPS do
|
||||
// revendedor. Por isso a asserção é sobre o TEXTO: é uma linha que some num
|
||||
// refactor sem nada mais quebrar.
|
||||
const icone = fs.readFileSync(path.join(RAIZ, "app/icon.tsx"), "utf8");
|
||||
expect(icone).toMatch(/export const dynamic\s*=\s*"force-dynamic"/);
|
||||
// E a marca tem de vir do resolvedor, não de literal.
|
||||
expect(icone).toMatch(/marcaDaSaida\(null\)/);
|
||||
});
|
||||
|
||||
it("o layout declara o ícone — é o que mata o pedido a /favicon.ico", () => {
|
||||
// O 404 de /favicon.ico não é barato: em produção ele devolve a
|
||||
// `app/not-found.tsx` inteira (19.435 bytes) para um pedido de ícone.
|
||||
const layout = fs.readFileSync(path.join(RAIZ, "app/layout.tsx"), "utf8");
|
||||
expect(layout).toMatch(/icons:\s*\{\s*icon:\s*"\/icon"\s*\}/);
|
||||
});
|
||||
});
|
||||
@@ -444,3 +444,128 @@ describe("catraca de marca hardcoded", () => {
|
||||
expect(semFase, `DIVIDA sem fase, ou fase declarada onde não é dívida:\n ${semFase.join("\n ")}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A SEGUNDA varredura: os arquivos que o GoTrue lê, e que a primeira nunca viu.
|
||||
*
|
||||
* A catraca de cima varre `.ts`/`.tsx` de `app|components|hooks|lib|workers` —
|
||||
* o código que embarca na imagem. Ela é cega para `supabase/templates/*.html` e
|
||||
* `supabase/config.toml`, e essa cegueira tinha consequência medida: dava para
|
||||
* zerar a lista de dívidas, ver a suíte inteira verde, e o cliente do
|
||||
* revendedor continuar recebendo "Confirme seu e-mail — DeskcommCRM" no
|
||||
* PRIMEIRO e-mail que ele abre na vida.
|
||||
*
|
||||
* Estes arquivos não são renderizados por nenhum TypeScript nosso: quem os
|
||||
* renderiza é o GoTrue, um processo de terceiro. Não há resolvedor a chamar —
|
||||
* o texto é empurrado por API pelo `hostgator-setup-kit/marca-emails.sh`, que
|
||||
* substitui os `__PLACEHOLDER__`. Por isso a guarda aqui é diferente em
|
||||
* NATUREZA da de cima: lá ela cobra `branding()`; aqui ela cobra placeholder.
|
||||
*
|
||||
* ⚠️ A REGRA DE COMENTÁRIO NÃO SE REAPROVEITA. `marcasNoTexto` ignora linha que
|
||||
* ABRE com `//`, `*` ou `/*`. Comentário de HTML é `<!-- … -->`, atravessa
|
||||
* várias linhas e as linhas do meio não abrem com nada. Copiar a função sem
|
||||
* ajustar faria a marca DENTRO de um comentário contar como vazamento e a marca
|
||||
* de verdade, na mesma linha de um `-->`, passar.
|
||||
*/
|
||||
describe("catraca de marca no que o GoTrue renderiza", () => {
|
||||
/** Comentário de HTML é um TRECHO, não um prefixo de linha. */
|
||||
function semComentariosHtml(fonte: string): string {
|
||||
return fonte.replace(/<!--[\s\S]*?-->/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* TOML: só linha que ABRE com `#`. Um `#` no meio da linha não é tratado como
|
||||
* comentário DE PROPÓSITO — `#506d48` dentro de uma string seria decapitado, e
|
||||
* uma marca depois dele sumiria. Contar demais aqui custa uma linha de
|
||||
* allowlist; contar de menos custa a marca vazando com o gate verde.
|
||||
*/
|
||||
function semComentariosToml(fonte: string): string {
|
||||
return fonte
|
||||
.split("\n")
|
||||
.filter((linha) => !linha.trimStart().startsWith("#"))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const ALVOS: { arquivo: string; limpar: (f: string) => string }[] = [
|
||||
{ arquivo: "supabase/templates/confirmation.html", limpar: semComentariosHtml },
|
||||
{ arquivo: "supabase/templates/recovery.html", limpar: semComentariosHtml },
|
||||
{ arquivo: "supabase/config.toml", limpar: semComentariosToml },
|
||||
];
|
||||
|
||||
const CONGELADO_SUPABASE: Record<string, EntradaDeMarca> = {
|
||||
"supabase/config.toml": {
|
||||
categoria: "DEV",
|
||||
motivo:
|
||||
"config do Supabase LOCAL (o `supabase start` de dev e do CI). NÃO embarca na imagem e NÃO alcança clone nenhum: um self-hoster usa um projeto na nuvem do Supabase, cuja config de auth vem do marca-emails.sh, ou um GoTrue próprio, que lê env. `project_id` ainda nomeia os contêineres locais (supabase_auth_deskcomm-crm) e os assuntos são o que a suíte local envia",
|
||||
marcas: ["deskcomm-crm", "deskcommcrm", "deskcommcrm"],
|
||||
},
|
||||
};
|
||||
|
||||
const encontradoAqui = new Map<string, string[]>();
|
||||
for (const { arquivo, limpar } of ALVOS) {
|
||||
const marcas = marcasNoTexto(limpar(fs.readFileSync(path.join(RAIZ, arquivo), "utf8")));
|
||||
if (marcas.length > 0) encontradoAqui.set(arquivo, marcas);
|
||||
}
|
||||
|
||||
it("os arquivos varridos existem e os modelos têm placeholder", () => {
|
||||
// Vacuidade em duas pontas. Se os arquivos sumissem de lugar, a varredura
|
||||
// devolveria vazio e os casos abaixo passariam vigiando o nada; e se os
|
||||
// modelos perdessem o `__APP_NAME__`, "sem marca" passaria a significar
|
||||
// "sem nome nenhum no e-mail", que é outro defeito com o mesmo sintoma.
|
||||
for (const { arquivo } of ALVOS) {
|
||||
expect(fs.existsSync(path.join(RAIZ, arquivo)), `${arquivo} sumiu`).toBe(true);
|
||||
}
|
||||
for (const modelo of ["supabase/templates/confirmation.html", "supabase/templates/recovery.html"]) {
|
||||
const texto = fs.readFileSync(path.join(RAIZ, modelo), "utf8");
|
||||
expect(texto, `${modelo} não substitui a marca`).toContain("__APP_NAME__");
|
||||
expect(texto, `${modelo} não substitui o accent`).toContain("__ACCENT__");
|
||||
}
|
||||
});
|
||||
|
||||
it("comentário de HTML não conta, e `-->` no meio da linha não engole o resto", () => {
|
||||
expect(marcasNoTexto(semComentariosHtml("<!-- fala do DeskcommCRM -->"))).toEqual([]);
|
||||
expect(marcasNoTexto(semComentariosHtml("<!--\n DeskcommCRM\n em várias linhas\n-->"))).toEqual([]);
|
||||
// O caso que a regra de `//` erraria: marca REAL depois do fecho.
|
||||
expect(marcasNoTexto(semComentariosHtml("<!-- nota --> Sua conta no DeskcommCRM"))).toEqual([
|
||||
"deskcommcrm",
|
||||
]);
|
||||
// E a marca fora de comentário nenhum continua contando.
|
||||
expect(marcasNoTexto(semComentariosHtml("<p>conta no DeskcommCRM</p>"))).toEqual(["deskcommcrm"]);
|
||||
});
|
||||
|
||||
it("comentário de TOML não conta, mas `#` dentro de string não vira comentário", () => {
|
||||
expect(marcasNoTexto(semComentariosToml("# Supabase CLI config — DeskcommCRM"))).toEqual([]);
|
||||
expect(marcasNoTexto(semComentariosToml('cor = "#506d48" # DeskcommCRM'))).toEqual([
|
||||
"deskcommcrm",
|
||||
]);
|
||||
expect(marcasNoTexto(semComentariosToml('subject = "Olá — DeskcommCRM"'))).toEqual(["deskcommcrm"]);
|
||||
});
|
||||
|
||||
it("nenhum arquivo do GoTrue fixa a marca fora da lista", () => {
|
||||
const novos = [...encontradoAqui.keys()].filter((f) => !(f in CONGELADO_SUPABASE));
|
||||
expect(
|
||||
novos,
|
||||
`Marca hardcoded em arquivo que o GoTrue renderiza.\n` +
|
||||
`Use o placeholder __APP_NAME__ (quem substitui é hostgator-setup-kit/marca-emails.sh):\n` +
|
||||
novos.map((f) => ` ${f} ${JSON.stringify(encontradoAqui.get(f))}`).join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("a lista do GoTrue não guarda arquivo que já não tem marca", () => {
|
||||
const obsoletos = Object.keys(CONGELADO_SUPABASE).filter((f) => !encontradoAqui.has(f));
|
||||
expect(obsoletos, `apague a linha destes de CONGELADO_SUPABASE:\n ${obsoletos.join("\n ")}`).toEqual([]);
|
||||
});
|
||||
|
||||
it("arquivo congelado do GoTrue não mudou de conjunto sem a lista acompanhar", () => {
|
||||
for (const [arquivo, entrada] of Object.entries(CONGELADO_SUPABASE)) {
|
||||
expect(encontradoAqui.get(arquivo) ?? [], arquivo).toEqual([...entrada.marcas].sort());
|
||||
}
|
||||
});
|
||||
|
||||
it("os dois modelos de e-mail não têm marca nenhuma — é o estado que se defende", () => {
|
||||
// Explícito, e não só implícito na ausência de linha na allowlist: é ESTE
|
||||
// caso que falha quando alguém reescreve "no DeskcommCRM" num template.
|
||||
expect(encontradoAqui.has("supabase/templates/confirmation.html")).toBe(false);
|
||||
expect(encontradoAqui.has("supabase/templates/recovery.html")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user