feat(marca): permita um logo para cada tema

This commit is contained in:
vitorlacerdadigital
2026-09-24 07:08:56 +00:00
parent 71936c18c1
commit 641e6114cc
31 changed files with 1087 additions and 165 deletions
+6
View File
@@ -0,0 +1,6 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Escolha um logo para cada tema da marca
---
A tela de marca aceita uma imagem opcional para o tema escuro, exibida sem moldura branca. O logo atual continua no tema claro e conserva a proteção de contraste no escuro quando não há uma segunda imagem. Instalação e organizações mantêm suas próprias marcas; remover uma arte não apaga a outra. Crédito: @vitorlacerdadigital.
+32 -14
View File
@@ -53,7 +53,7 @@ export default async function PublicLayout({ children }: { children: React.React
<IdiomaProvider locale={locale}>
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<div className="w-full max-w-sm space-y-6">
{marca.logoUrl ? (
{marca.logoUrl || marca.logoDarkUrl ? (
<div className="flex justify-center">
{/*
<img> em vez de next/image pelo mesmo motivo da barra lateral: a URL
@@ -71,19 +71,37 @@ export default async function PublicLayout({ children }: { children: React.React
"primeira <img> da página", e uma asserção de negação com seletor
largo passa sozinha assim que outra imagem entra na tela.
*/}
{/* O chip `dark:bg-white` é o mesmo da barra lateral
(`components/shell/Sidebar.tsx`): esta tela também respeita
`data-theme` (o `ThemeProvider` embrulha a raiz inteira, login
incluso), então um logo escuro contra `--color-surface` escuro tem
o mesmo problema de contraste aqui. */}
<div className="rounded-md dark:bg-white dark:px-3 dark:py-2 dark:shadow-sm">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
data-testid="logo-da-fachada"
src={marca.logoUrl}
alt={marca.nome}
className="h-10 w-auto max-w-[12rem] object-contain"
/>
<div
className={
marca.logoDarkUrl
? "rounded-md"
: "rounded-md dark:bg-white dark:px-3 dark:py-2 dark:shadow-sm"
}
>
{marca.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
data-testid="logo-da-fachada"
src={marca.logoUrl}
alt={marca.nome}
className={
marca.logoDarkUrl
? "h-10 w-auto max-w-[12rem] object-contain dark:hidden"
: "h-10 w-auto max-w-[12rem] object-contain"
}
/>
) : (
<span className="dark:hidden">{marca.nome}</span>
)}
{marca.logoDarkUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
data-testid="logo-escuro-da-fachada"
src={marca.logoDarkUrl}
alt={marca.nome}
className="hidden h-10 w-auto max-w-[12rem] object-contain dark:block"
/>
) : null}
</div>
</div>
) : marcaEhADoProduto({ name: marca.nome, logoUrl: null }) ? (
+6 -1
View File
@@ -46,6 +46,7 @@ interface Props {
* interface aparece com o nome em texto.
*/
readonly logoEmVigor: string | null;
readonly logoEscuroEmVigor?: string | null;
/** O que apareceria SEM o arquivo subido — a URL colada no `.env`, se houver. */
readonly logoDoAmbiente: string | null;
readonly origens: { readonly nome: string; readonly logoUrl: string; readonly cor: string };
@@ -68,6 +69,7 @@ export function FormularioDaMarca({
gravada,
nomeEmVigor,
logoEmVigor,
logoEscuroEmVigor,
logoDoAmbiente,
origens,
definidoNestaTela,
@@ -339,7 +341,10 @@ export function FormularioDaMarca({
escopo="instalacao"
// Literal, nunca memoizado: a identidade deste objeto é o que diz ao
// campo que houve render NOVO do servidor. Ver os Props de CampoDeLogo.
logoDaCamada={{ url: gravada.logo_path ? logoEmVigor : null }}
logoDaCamada={{
url: gravada.logo_path ? logoEmVigor : null,
escuraUrl: logoEscuroEmVigor,
}}
logoHerdado={logoDoAmbiente}
origemDoHerdado="do arquivo de instalação do servidor"
nomeEmVigor={nomeEmVigor}
+1
View File
@@ -102,6 +102,7 @@ export default async function Page() {
}}
nomeEmVigor={marca.name}
logoEmVigor={marca.logoUrl}
logoEscuroEmVigor={marca.logoDarkUrl}
logoDoAmbiente={semOArquivo.logoUrl}
origens={marca.origens}
// `seeded_from_env` ligado significa que a linha é cópia do arquivo de
+39 -18
View File
@@ -84,6 +84,9 @@ export const dynamic = "force-dynamic";
*/
const escopoSchema = z.enum(["instalacao", "organizacao"]);
type Escopo = z.infer<typeof escopoSchema>;
const temaSchema = z.enum(["claro", "escuro"]).default("claro");
type TemaDoLogo = z.infer<typeof temaSchema>;
const campoDoLogo = (tema: TemaDoLogo) => (tema === "escuro" ? "logo_dark_path" : "logo_path");
/**
* 10 trocas de logo por pessoa a cada 5 min.
@@ -187,22 +190,19 @@ async function abrirContexto(escopo: Escopo): Promise<{ ctx: Contexto } | { recu
}
/** O caminho HOJE gravado, lido do BANCO. Nunca do cliente. */
async function caminhoGravado(ctx: Contexto): Promise<string | null> {
async function caminhoGravado(ctx: Contexto, tema: TemaDoLogo): Promise<string | null> {
const campo = campoDoLogo(tema);
const admin = createAdminClient();
if (ctx.escopo === "instalacao") {
const { data } = await admin
.from("platform_branding")
.select("logo_path")
.eq("id", 1)
.maybeSingle();
return (data as { logo_path?: string | null } | null)?.logo_path ?? null;
const { data } = await admin.from("platform_branding").select(campo).eq("id", 1).maybeSingle();
return (data as Record<string, string | null> | null)?.[campo] ?? null;
}
const { data } = await admin
.from("organizations")
.select("settings")
.eq("id", ctx.orgId)
.maybeSingle();
return marcaDaOrganizacaoDeSettings(data?.settings ?? null)?.logo_path ?? null;
return marcaDaOrganizacaoDeSettings(data?.settings ?? null)?.[campo] ?? null;
}
/**
@@ -218,12 +218,19 @@ async function caminhoGravado(ctx: Contexto): Promise<string | null> {
* outros fazem read-modify-write do jsonb inteiro. O `row_count` de volta é o que
* distingue "gravou" de "não gravou".
*/
async function gravarCaminho(ctx: Contexto, caminho: string | null): Promise<Recusa | null> {
async function gravarCaminho(
ctx: Contexto,
caminho: string | null,
tema: TemaDoLogo,
): Promise<Recusa | null> {
const admin = createAdminClient();
if (ctx.escopo === "instalacao") {
const { error } = await admin
.from("platform_branding")
.upsert({ id: 1, logo_path: caminho, seeded_from_env: false }, { onConflict: "id" });
.upsert(
{ id: 1, [campoDoLogo(tema)]: caminho, seeded_from_env: false },
{ onConflict: "id" },
);
if (error) {
logger.error("[marca/logo] gravação da instalação falhou", {
codigo: error.code,
@@ -243,7 +250,8 @@ async function gravarCaminho(ctx: Contexto, caminho: string | null): Promise<Rec
return null;
}
const { data, error } = await admin.rpc("fn_definir_logo_da_organizacao", {
const { data, error } = await admin.rpc("fn_definir_logo_por_tema_da_organizacao", {
p_tema: tema,
p_org: ctx.orgId,
p_actor: ctx.userId,
p_path: caminho,
@@ -317,12 +325,17 @@ async function registrarAuditoria(
req: NextRequest,
requestId: string,
acao: "definido" | "removido",
tema: TemaDoLogo,
): Promise<void> {
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
const userAgent = req.headers.get("user-agent") ?? null;
// FORMA, nunca IDENTIDADE — mesma disciplina de `resolve.ts`. O caminho do
// arquivo não entra: a trilha é lida por quem opera a plataforma inteira.
const metadata = { fields_changed: ["logo_path"], logo_definido: acao === "definido" };
const metadata = {
fields_changed: [campoDoLogo(tema)],
logo_definido: acao === "definido",
tema,
};
if (ctx.escopo === "instalacao") {
await audit({
@@ -367,6 +380,10 @@ export async function POST(req: NextRequest): Promise<Response> {
return fail("validation_failed", "Campo 'escopo' inválido.", 422, { requestId });
}
const temaLido = temaSchema.safeParse(form?.get("tema") ?? undefined);
if (!temaLido.success)
return fail("validation_failed", "Campo 'tema' inválido.", 422, { requestId });
const tema = temaLido.data;
const aberto = await abrirContexto(escopoLido.data);
if ("recusa" in aberto) {
return fail(aberto.recusa.codigo, aberto.recusa.mensagem, aberto.recusa.status, { requestId });
@@ -420,7 +437,7 @@ export async function POST(req: NextRequest): Promise<Response> {
});
}
const anterior = await caminhoGravado(ctx);
const anterior = await caminhoGravado(ctx, tema);
const caminho = caminhoNovoDoLogo(ctx.prefixo, extensaoDe(tipo));
const { error: erroUp } = await createAdminClient()
@@ -431,7 +448,7 @@ export async function POST(req: NextRequest): Promise<Response> {
return fail("internal_error", "Erro ao subir o logo.", 500, { requestId });
}
const recusa = await gravarCaminho(ctx, caminho);
const recusa = await gravarCaminho(ctx, caminho, tema);
if (recusa) {
// A gravação falhou DEPOIS do upload: o arquivo novo é que vira órfão, não o
// antigo. Tentar apagá-lo aqui seria o caminho certo e não é obrigatório —
@@ -441,7 +458,7 @@ export async function POST(req: NextRequest): Promise<Response> {
}
await apagarAnterior(ctx, anterior);
await registrarAuditoria(ctx, req, requestId, "definido");
await registrarAuditoria(ctx, req, requestId, "definido", tema);
return ok(
{ logo_path: caminho, logo_url: urlPublicaDoLogo(caminho, baseDoStorage()) },
@@ -467,6 +484,10 @@ export async function DELETE(req: NextRequest): Promise<Response> {
return fail("validation_failed", "Parâmetro 'escopo' inválido.", 422, { requestId });
}
const temaLido = temaSchema.safeParse(new URL(req.url).searchParams.get("tema") ?? undefined);
if (!temaLido.success)
return fail("validation_failed", "Parâmetro 'tema' inválido.", 422, { requestId });
const tema = temaLido.data;
const aberto = await abrirContexto(escopoLido.data);
if ("recusa" in aberto) {
return fail(aberto.recusa.codigo, aberto.recusa.mensagem, aberto.recusa.status, { requestId });
@@ -485,12 +506,12 @@ export async function DELETE(req: NextRequest): Promise<Response> {
});
}
const anterior = await caminhoGravado(ctx);
const recusa = await gravarCaminho(ctx, null);
const anterior = await caminhoGravado(ctx, tema);
const recusa = await gravarCaminho(ctx, null, tema);
if (recusa) return fail(recusa.codigo, recusa.mensagem, recusa.status, { requestId });
await apagarAnterior(ctx, anterior);
await registrarAuditoria(ctx, req, requestId, "removido");
await registrarAuditoria(ctx, req, requestId, "removido", tema);
return ok({ logo_path: null, logo_url: null }, { requestId });
}
+1
View File
@@ -167,6 +167,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
// do produtor existir, de propósito — foi o que fez o upload por organização
// ser só a camada, sem mais uma passada pela casca inteira.
const marcaDoTenant = {
logoDarkUrl: marca.logoDarkUrl ?? null,
...(marca.origens.nome === "organizacao" ? { nome: marca.name } : {}),
...(marca.origens.logoUrl === "organizacao" && marca.logoUrl !== null
? { logoUrl: marca.logoUrl }
+10 -5
View File
@@ -30,7 +30,12 @@ import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cssDaMarca, ESCOPO_DA_ORGANIZACAO } from "@/lib/branding/css";
import { avisosDaMarca, type Aviso, type DistanciaAteSuaCor, type Tom } from "@/lib/branding/linguagem";
import {
avisosDaMarca,
type Aviso,
type DistanciaAteSuaCor,
type Tom,
} from "@/lib/branding/linguagem";
import { ehHexValido, K, normalizarHex } from "@/lib/branding/rampa";
import { REGUA_DO_PRODUTO } from "@/lib/branding/regua-do-produto";
import {
@@ -349,8 +354,11 @@ export function FormularioDaMarcaDaOrganizacao({ gravada, instalacao, ambiente }
// campo que houve render NOVO do servidor. Ver os Props de CampoDeLogo.
logoDaCamada={{
url: resolvida.origens.logoUrl === "organizacao" ? resolvida.logoUrl : null,
escuraUrl:
resolvida.origens.logoDarkUrl === "organizacao" ? resolvida.logoDarkUrl : null,
}}
logoHerdado={semAOrganizacao.logoUrl}
logoEscuroHerdado={semAOrganizacao.logoDarkUrl}
// Texto-fonte cru, não traduzido aqui: CampoDeLogo já chama t()
// internamente sobre esta prop (ver componente compartilhado).
origemDoHerdado="de quem instalou o sistema"
@@ -378,10 +386,7 @@ export function FormularioDaMarcaDaOrganizacao({ gravada, instalacao, ambiente }
</div>
<div>
<LinhaDeOrigem
campo={t("Nome")}
valor={origemEmPortugues(resolvida.origens.nome, t)}
/>
<LinhaDeOrigem campo={t("Nome")} valor={origemEmPortugues(resolvida.origens.nome, t)} />
<LinhaDeOrigem campo={t("Cor")} valor={origemEmPortugues(resolvida.origens.cor, t)} />
<LinhaDeOrigem
campo={t("Logo")}
+10 -22
View File
@@ -56,10 +56,7 @@ async function marcaResolvida(): Promise<{
readonly marca: MarcaResolvida;
}> {
const linha = await marcaDaInstalacao();
const marca = resolverMarca(
[camadaDaInstalacao(linha), camadaDoAmbiente(env)],
REGUA_DO_PRODUTO,
);
const marca = resolverMarca([camadaDaInstalacao(linha), camadaDoAmbiente(env)], REGUA_DO_PRODUTO);
return { linha, marca };
}
@@ -89,14 +86,7 @@ export async function generateMetadata(): Promise<Metadata> {
"Centralize o atendimento por WhatsApp num funil só. Agentes de IA resolvem o que dá pra resolver e passam para o time humano o que importa — com tudo registrado. Multi-tenant, LGPD-nativo, feito para operações brasileiras.",
applicationName: name,
authors: [{ name }],
keywords: [
"CRM",
"atendimento",
"WhatsApp",
"IA conversacional",
"LGPD",
"multi-tenant",
],
keywords: ["CRM", "atendimento", "WhatsApp", "IA conversacional", "LGPD", "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)
@@ -263,16 +253,19 @@ async function MarcaDosClientComponents({ children }: { children: React.ReactNod
// mandá-los engordaria o payload do RSC de TODA página com dado que ninguém lê.
return (
<MarcaDaInstalacaoProvider
marca={{ name: marca.name, logoUrl: marca.logoUrl, initial: marca.initial }}
marca={{
name: marca.name,
logoUrl: marca.logoUrl,
logoDarkUrl: marca.logoDarkUrl,
initial: marca.initial,
}}
>
{children}
</MarcaDaInstalacaoProvider>
);
}
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html
lang="pt-BR"
@@ -293,12 +286,7 @@ export default function RootLayout({
<MarcaDosClientComponents>
<ThemeProvider>{children}</ThemeProvider>
</MarcaDosClientComponents>
<Toaster
position="top-right"
richColors
closeButton
duration={4000}
/>
<Toaster position="top-right" richColors closeButton duration={4000} />
</Providers>
</body>
</html>
+83 -27
View File
@@ -74,12 +74,13 @@ interface Props {
* este objeto num `useMemo` ou num módulo devolveria o defeito em silêncio —
* é o que `tests/unit/marca-previa-do-logo-sem-refresh.test.tsx` (3) vigia.
*/
readonly logoDaCamada: { readonly url: string | null };
readonly logoDaCamada: { readonly url: string | null; readonly escuraUrl?: string | null };
/**
* O que o produto mostra quando esta camada não tem nada. `null` = ninguém tem
* logo, e a interface aparece com o NOME em texto.
*/
readonly logoHerdado: string | null;
readonly logoEscuroHerdado?: string | null;
/** Uma frase dizendo de quem é o logo herdado ("do sistema", "da instalação"). */
readonly origemDoHerdado: string;
/** O nome em vigor — vira o `alt` da prévia e o texto do caso sem logo. */
@@ -99,6 +100,7 @@ export function CampoDeLogo({
escopo,
logoDaCamada,
logoHerdado,
logoEscuroHerdado,
origemDoHerdado,
nomeEmVigor,
}: Props) {
@@ -170,13 +172,18 @@ export function CampoDeLogo({
* `logoDaCamada`, e o tipo dele existe por essa razão (ver os Props).
*/
const [logoGravado, setLogoGravado] = useState<string | null>(logoDaCamada.url);
const [logoEscuroGravado, setLogoEscuroGravado] = useState<string | null>(
logoDaCamada.escuraUrl ?? null,
);
const [ultimoDoServidor, setUltimoDoServidor] = useState(logoDaCamada);
if (logoDaCamada !== ultimoDoServidor) {
setUltimoDoServidor(logoDaCamada);
setLogoGravado(logoDaCamada.url);
setLogoEscuroGravado(logoDaCamada.escuraUrl ?? null);
}
const emVigor = logoGravado ?? logoHerdado;
const escuroEmVigor = logoEscuroGravado ?? (logoGravado ? null : logoEscuroHerdado);
/**
* O `logo_url` que a rota acabou de gravar para ESTA camada.
@@ -189,9 +196,9 @@ export function CampoDeLogo({
* DELETE não apagar a prévia.
*/
async function logoDaResposta(resposta: Response): Promise<string | null | undefined> {
const corpo = (await resposta.json().catch(() => null)) as
| { data?: { logo_url?: string | null } }
| null;
const corpo = (await resposta.json().catch(() => null)) as {
data?: { logo_url?: string | null };
} | null;
return corpo?.data?.logo_url;
}
@@ -205,18 +212,21 @@ export function CampoDeLogo({
* onde a mensagem certa depende de onde a pessoa está.
*/
async function razaoDaFalha(resposta: Response): Promise<string> {
const corpo = (await resposta.json().catch(() => null)) as
| { error?: { code?: string; message?: string } }
| null;
const corpo = (await resposta.json().catch(() => null)) as {
error?: { code?: string; message?: string };
} | null;
const codigo = corpo?.error?.code ?? "";
return t(ERRO_EM_PORTUGUES[codigo] ?? corpo?.error?.message ?? "Não consegui trocar o logo agora.");
return t(
ERRO_EM_PORTUGUES[codigo] ?? corpo?.error?.message ?? "Não consegui trocar o logo agora.",
);
}
async function enviar(arquivo: File) {
async function enviar(arquivo: File, tema: "claro" | "escuro" = "claro") {
setEnviando(true);
try {
const corpo = new FormData();
corpo.set("escopo", escopo);
corpo.set("tema", tema);
corpo.set("file", arquivo);
const resposta = await fetch("/api/v1/marca/logo", { method: "POST", body: corpo });
if (!resposta.ok) {
@@ -225,7 +235,10 @@ export function CampoDeLogo({
}
toast.success(t("Logo atualizado."));
const gravado = await logoDaResposta(resposta);
if (gravado !== undefined) setLogoGravado(gravado);
if (gravado !== undefined) {
if (tema === "escuro") setLogoEscuroGravado(gravado);
else setLogoGravado(gravado);
}
startTransition(() => router.refresh());
} finally {
setEnviando(false);
@@ -235,10 +248,12 @@ export function CampoDeLogo({
}
}
async function remover() {
async function remover(tema: "claro" | "escuro" = "claro") {
setEnviando(true);
try {
const resposta = await fetch(`/api/v1/marca/logo?escopo=${escopo}`, { method: "DELETE" });
const resposta = await fetch(`/api/v1/marca/logo?escopo=${escopo}&tema=${tema}`, {
method: "DELETE",
});
if (!resposta.ok) {
toast.error(await razaoDaFalha(resposta));
return;
@@ -247,7 +262,10 @@ export function CampoDeLogo({
// A rota devolve `logo_url: null` — "esta camada ficou sem logo próprio" —,
// e é isso que faz a prévia cair no herdado sem esperar o refresh.
const gravado = await logoDaResposta(resposta);
if (gravado !== undefined) setLogoGravado(gravado);
if (gravado !== undefined) {
if (tema === "escuro") setLogoEscuroGravado(gravado);
else setLogoGravado(gravado);
}
startTransition(() => router.refresh());
} finally {
setEnviando(false);
@@ -255,7 +273,11 @@ export function CampoDeLogo({
}
return (
<div className="space-y-4" data-campo-de-logo={escopo} data-hidratado={hidratado ? "" : undefined}>
<div
className="space-y-4"
data-campo-de-logo={escopo}
data-hidratado={hidratado ? "" : undefined}
>
<div className="space-y-2">
<Label htmlFor={`logo-${escopo}`}>{t("Logo")}</Label>
<div className="flex flex-wrap items-center gap-3">
@@ -275,7 +297,12 @@ export function CampoDeLogo({
className="max-w-xs text-sm file:mr-3 file:cursor-pointer file:rounded-sm file:border file:border-border file:bg-surface-elevated file:px-3 file:py-1.5 file:text-sm"
/>
{logoGravado ? (
<Button type="button" variant="outline" onClick={() => void remover()} disabled={enviando}>
<Button
type="button"
variant="outline"
onClick={() => void remover()}
disabled={enviando}
>
{t("Remover")}
</Button>
) : null}
@@ -288,6 +315,39 @@ export function CampoDeLogo({
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`logo-escuro-${escopo}`}>{t("Logo para o tema escuro (opcional)")}</Label>
<div className="flex flex-wrap items-center gap-3">
<input
id={`logo-escuro-${escopo}`}
type="file"
accept="image/png,image/jpeg"
disabled={enviando}
onChange={(e) => {
const arquivo = e.target.files?.[0];
if (arquivo) void enviar(arquivo, "escuro");
e.target.value = "";
}}
className="max-w-xs text-sm file:mr-3 file:cursor-pointer file:rounded-sm file:border file:border-border file:bg-surface-elevated file:px-3 file:py-1.5 file:text-sm"
/>
{logoEscuroGravado ? (
<Button
type="button"
variant="outline"
onClick={() => void remover("escuro")}
disabled={enviando}
>
{t("Remover logo escuro")}
</Button>
) : null}
</div>
<p className="text-xs text-text-muted">
{t(
"Use uma versão legível sobre fundo escuro. Ela aparece sem moldura branca. Sem ela, o logo padrão mantém a proteção de contraste. PNG ou JPG, até 512 KB.",
)}
</p>
</div>
<div className="space-y-2">
<p className="text-sm text-text-muted">
{/*
@@ -312,19 +372,12 @@ export function CampoDeLogo({
className="flex h-24 items-center justify-center rounded-sm border border-border px-4"
style={{ backgroundColor: fundo }}
>
{emVigor ? (
// O chip claro na aparência escura é o MESMO que a barra
// lateral e a tela de entrada aplicam de verdade
// (`components/shell/Sidebar.tsx`, `app/(public)/layout.tsx`):
// esta prévia deixaria de ser prévia se mostrasse o logo cru
// onde o app real desenha um chip por baixo. Aqui não dá pra
// usar a variante `dark:` do Tailwind — as duas caixas
// renderizam lado a lado no MESMO tema real, simulando os
// dois via `style` — então a condição é o rótulo da caixa, não
// o tema da página.
{(rotulo === t("Aparência escura") ? escuroEmVigor || emVigor : emVigor) ? (
// A arte específica dispensa a moldura; o logo único conserva
// a proteção do #659. A prévia simula ambos os temas lado a lado.
<span
className={
rotulo === t("Aparência escura")
rotulo === t("Aparência escura") && !escuroEmVigor
? "rounded-md bg-white px-2 py-1 shadow-sm"
: undefined
}
@@ -337,7 +390,10 @@ export function CampoDeLogo({
de proporção desconhecida. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={emVigor}
src={
(rotulo === t("Aparência escura") ? escuroEmVigor || emVigor : emVigor) ??
undefined
}
alt={nomeEmVigor}
className="max-h-12 w-auto max-w-full object-contain"
/>
+34 -18
View File
@@ -112,6 +112,12 @@ export function SidebarContent({
* descer para ele — que é o contrário do que a precedência por campo promete.
*/
const logo = activeOrg?.marca?.logoUrl || brand.logoUrl;
const logoEscuro =
activeOrg?.marca?.logoDarkUrl !== undefined
? activeOrg.marca.logoDarkUrl
: activeOrg?.marca?.logoUrl
? null
: brand.logoDarkUrl;
// Só quando NINGUÉM — nem a instalação, nem a organização — pôs marca própria:
// é a condição de `lib/branding.ts`, avaliada sobre o que a barra vai mostrar.
const marcaDoProduto = marcaEhADoProduto({ name: nome, logoUrl: logo ?? null });
@@ -124,30 +130,40 @@ export function SidebarContent({
collapsed ? "justify-center" : "justify-start",
)}
>
{logo && !collapsed ? (
// A moldura clara vale SÓ para o logo enviado por quem hospeda. A arte
// do produto (ramo `marcaDoProduto`, logo abaixo) já é desenhada para os
// dois temas e não precisa dela — pôr a moldura ali seria dar o remédio
// a quem não tem a doença.
// Chip claro só no tema escuro: a arte enviada é de quem hospeda, sem
// garantia de que tenha contraste contra `--color-surface` escuro
// (`#1d1c17`). Sem isto, todo logo escuro/colorido — a maioria do que
// se sobe pensando em fundo claro — some no tema escuro (issue: logo
// da Dra. Mariana Nascimento, azul-marinho sobre quase-preto). O chip
// é condicional ao TEMA, não à cor do logo (não dá pra inspecionar
// pixel de uma URL externa em server component), então ele aparece
// para qualquer logo — inclusive um já pensado pra fundo escuro, que
// fica com uma moldura branca de sobra. Troca aceita: pior caso
// "moldura desnecessária" é sempre melhor que pior caso "logo
// invisível".
<div className="rounded-md dark:bg-white dark:px-2 dark:py-1 dark:shadow-sm">
{(logo || logoEscuro) && !collapsed ? (
// Sem arte própria para o escuro, preserva a proteção de contraste.
<div
className={cn(
"rounded-md",
!logoEscuro && "dark:bg-white dark:px-2 dark:py-1 dark:shadow-sm",
)}
>
{/* <img> em vez de next/image de propósito: a URL vem de quem hospeda
(banco ou .env), e next/image exige allowlist de domínios fechada em
build — a imagem pré-buildada rejeitaria o domínio do self-hoster.
Altura fixa e largura livre porque a arte enviada tem proporção
desconhecida; forçar as duas distorceria o logo de quem configurou. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={logo} alt={nome} className="h-7 w-auto max-w-[10rem] object-contain" />
{logo ? (
<img
src={logo}
alt={nome}
className={cn(
"h-7 w-auto max-w-[10rem] object-contain",
logoEscuro && "dark:hidden",
)}
/>
) : (
<span className="dark:hidden">{nome}</span>
)}
{logoEscuro ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logoEscuro}
alt={nome}
className="hidden h-7 w-auto max-w-[10rem] object-contain dark:block"
/>
) : null}
</div>
) : marcaDoProduto ? (
// O desenho do produto, inline (ver `components/branding/MarcaDoProduto.tsx`):
@@ -72,7 +72,7 @@
{ "id": "updatebranding", "lane": "escrita", "col": 2, "type": "api", "label": "updateBranding (server action)", "sublabel": "gate is_platform_admin + admin client + invalidarMarcaDaInstalacao()" },
{ "id": "updateorg", "lane": "escrita", "col": 2, "type": "api", "label": "updateMarcaDaOrganizacao (server action)", "sublabel": "RPC atômica: SELECT→spread→UPDATE no mesmo jsonb perderia visibility_mode" },
{ "id": "rotalogo", "lane": "escrita", "col": 2, "type": "api", "label": "POST/DELETE /api/v1/marca/logo", "sublabel": "gate POR ESCOPO · tipo pelos BYTES (file.type só no details) · 512 KB · sobe → grava → só então apaga, com o prefixo asseverado" },
{ "id": "rotalogo", "lane": "escrita", "col": 2, "type": "api", "label": "POST/DELETE /api/v1/marca/logo", "sublabel": "gate POR ESCOPO · tipo pelos BYTES (file.type só no details) · 512 KB por arte · claro/escuro opcionais · sobe → grava → só então apaga, com o prefixo asseverado" },
{ "id": "bucketlogos", "lane": "banco", "col": 3, "type": "database", "label": "storage/brand-logos (PÚBLICO)", "sublabel": "o 1º bucket público do repo · zero policy em storage.objects · <prefixo>/<uuid>.<png|jpg> · a URL é DERIVADA do caminho, nunca gravada" },
{ "id": "fnlogoorg", "lane": "banco", "col": 4, "type": "database", "label": "fn_definir_logo_da_organizacao (0158)", "sublabel": "assevera o prefixo contra p_org DENTRO do banco · merge no CAMPO · a 0157 ganhou forward-fix para não apagar logo_path" },
@@ -163,7 +163,7 @@
{ "id": "e58", "from": "rotalogo", "to": "platformbranding", "label": "escopo=instalacao: upsert de logo_path (CHECK ^platform/<uuid>.(png|jpg)) + seeded_from_env=false" },
{ "id": "e59", "from": "rotalogo", "to": "fnlogoorg", "label": "escopo=organizacao: RPC com row_count de volta — a catraca da issue #144" },
{ "id": "e60", "from": "fnlogoorg", "to": "orgbranding", "label": "jsonb_set em {branding} preservando app_name/accent_hex — merge no CAMPO, não no objeto" },
{ "id": "e61", "from": "bucketlogos", "to": "resolver", "label": "logoDaCamada(): o ARQUIVO vence a URL colada; urlPublicaDoLogo deriva a URL do caminho + host em runtime" },
{ "id": "e61", "from": "bucketlogos", "to": "resolver", "label": "logoDaCamada(): o ARQUIVO vence a URL colada; par de logos não mistura marcas entre camadas; urlPublicaDoLogo deriva a URL do caminho + host em runtime" },
{ "id": "e62", "from": "rotalogo", "to": "auditlog", "label": "reusa platform_branding.updated / org.branding_updated — código de ação novo não responderia pergunta nova" },
{ "id": "e63", "from": "layout", "to": "marcacliente", "label": "a MESMA marcaResolvida() do título e do CSS desce por prop, no payload do RSC — mesma rota de user/activeOrg" },
{ "id": "e64", "from": "marcacliente", "to": "interface", "label": "useMarcaDaInstalacao(): SSR e hidratação leem o mesmo objeto, então o logo do revendedor não troca de elemento no meio do caminho" }
+30
View File
@@ -0,0 +1,30 @@
# Logo por tema: compatibilidade e reversão
A mudança 0398 acrescenta `platform_branding.logo_dark_path` e o campo homônimo em `organizations.settings.branding`. O bucket e as regras de tamanho, formato, autorização e auditoria são os mesmos do logo padrão. O upload conserva os bytes da imagem.
A rota `POST/DELETE /api/v1/marca/logo` aceita `tema=claro|escuro`; omitir o tema conserva o contrato antigo (`claro`). A resposta mantém `logo_path` e `logo_url`, relativos ao tema pedido. O RPC antigo delega ao mesmo escritor com o tema claro. Nome/cor não alteram os ponteiros dos arquivos.
## Operação
Em Marca, envie o logo padrão e, se desejar, o logo para o tema escuro. A prévia mostra o resultado nos dois fundos. O segundo arquivo é opcional e deve ter contraste sobre fundo escuro; o sistema não modifica sua cor nem acrescenta moldura. O login usa a instalação; o menu usa a organização. E-mails continuam usando o logo padrão.
## Reversão
Para desfazer a escolha visual, remova o logo escuro pela própria tela. O padrão volta a aparecer no escuro com a proteção branca anterior.
Para reverter a versão do aplicativo, restaure a imagem anterior registrada na implantação. A migration é aditiva: deixe coluna, função e dados no banco; não é necessário apagar nenhum arquivo ou restaurar o banco para voltar ao código anterior. Essa versão ignora a arte escura, e o RPC legado continua funcionando. Preserve o backup e confira login, marca e funcionamento da aplicação após a reversão.
## Incorporação de uma solução upstream
Mantenha a alteração em um commit/PR independente. Em cada atualização, compare o comportamento oficial com: upload e remoção independentes, herança sem mistura de marcas, isolamento entre organizações e renderização clara/escura. Só retire a implementação local quando a oficial satisfizer esses casos. Se mudar o formato persistido, crie uma migration de transição preservando os arquivos; não remova a migration já aplicada nem volte o banco inteiro. A aceitação de uma contribuição depende dos mantenedores.
## Checklist de integração
- Entrada: campos da tela de marca, autorizados por escopo; os dois temas passam pela mesma rota.
- Saída: resolvedor → menu, login e prévia; e-mails permanecem no padrão claro.
- Registro: `org.branding_updated` ou `platform_branding.updated`, com tema e campo alterado.
- Acesso/configuração: páginas de marca já registradas na navegação; nenhum menu novo.
- Retorno: erros da rota aparecem na tela; a prévia aplica a resposta sem depender do refresh.
- Continuidade IA/humano: não se aplica, a mudança só representa a identidade visual.
- Próximo passo: remoção de cada arquivo restaura o fallback; não há fila nem atividade pendente.
- Mapa: `docs/architecture/marca-propria.architecture.json` descreve a mesma cadeia de consumidores.
+1
View File
@@ -446,6 +446,7 @@ ao cliente dele, e a tela de acesso é a primeira coisa que qualquer usuário v
| J10.6 | O instalador pergunta a cor da marca | `APP_ACCENT_HEX` no `install.sh`, com validação — o revendedor não recebe o verde do produto | PASS (`tests/shell/`) |
| J10.7 | Nome com apóstrofo (`Sant'Ana Odontologia`) | o `.env` sobrevive: 18/18 nos três consumidores de compose | PASS |
| J10.8 | Cor escura de marca não quebra o contraste | o anel de foco respeita o piso de 3:1 em ambos os temas | PASS (unit) |
| J10.9 | Dois logos, um por tema, com remoção independente | arte escura sem moldura na prévia, menu e login; remover apenas a escura preserva o padrão com a proteção anterior | `tests/e2e/logo-moldura-no-tema-escuro.spec.ts`, caso (7); ver evidência da execução no PR |
**Bug de produto achado ao executar (2026-08-14), e é o que justifica esta jornada
existir.** O caso J10.1 reprovou no CI, e não por defeito do teste: quem sobe o
+7 -1
View File
@@ -1,4 +1,4 @@
<!-- traduzido-de: docs/white-label.md@e1d374bb48e0 -->
<!-- traduzido-de: docs/white-label.md@4878d07fc8e5 -->
[🇧🇷 Português](white-label.md) · 🇺🇸 English · [🇪🇸 Español](white-label.es.md)
@@ -200,3 +200,9 @@ Full installation guide: [`hostgator-setup-kit/README.md`](../hostgator-setup-ki
*This document exists in three languages, and the prerequisite that the previous version of this footer named has been paid for: the translations carry, on their first line, a seal with the hash of the original, and editing `docs/white-label.md` without re-sealing fails `pnpm test:unit`. After translating, re-seal with `pnpm exec tsx scripts/selar-traducao.ts --todas`.*
*The three READMEs were deliberately left **out** of the seal. They are the most edited file in the repository: with a seal, every fix would become a PR blocked until ~490 lines were re-translated twice — and the realistic outcome of that is not up-to-date translations, it is somebody re-sealing without translating, which is the only way for the seal to die. They come in when someone chooses to pay that cost with their eyes open.*
## A logo for each theme
In **Brand**, **Logo** remains the default image (light theme and email). **Logo for dark theme (optional)** accepts a second PNG or JPG image up to 512 KB, designed for dark backgrounds. It appears without a white frame in the sidebar, installation sign-in screen and preview. Without the second image, the default logo keeps its white protection in dark mode. Each image has its own removal control; removing the dark image restores the previous behavior.
An organization that uploads its own default logo stops inheriting the installation’s dark image, preventing accidental mixing of brands. Without its own logos, it inherits the installation’s pair. Sign-in always uses the installation brand. [Compatibility and rollback](runbooks/logo-por-tema.md).
+7 -1
View File
@@ -1,4 +1,4 @@
<!-- traduzido-de: docs/white-label.md@e1d374bb48e0 -->
<!-- traduzido-de: docs/white-label.md@4878d07fc8e5 -->
[🇧🇷 Português](white-label.md) · [🇺🇸 English](white-label.en.md) · 🇪🇸 Español
@@ -200,3 +200,9 @@ Guía completa de instalación: [`hostgator-setup-kit/README.md`](../hostgator-s
*Este documento existe en tres idiomas, y el requisito que la versión anterior de este pie nombraba fue pagado: las traducciones llevan en la primera línea un sello con el hash del original, y editar `docs/white-label.md` sin volver a sellar reprueba `pnpm test:unit`. Después de traducir, vuelve a sellar con `pnpm exec tsx scripts/selar-traducao.ts --todas`.*
*Los tres README quedaron **fuera** del sello a propósito. Son el archivo más editado del repositorio: con sello, cada arreglo se volvería un PR bloqueado hasta que ~490 líneas fueran retraducidas dos veces — y el desenlace realista de eso no es traducción al día, es alguien volviendo a sellar sin traducir, que es la única manera de que el sello muera. Entran cuando alguien quiera pagar ese coste con los ojos abiertos.*
## Un logo para cada tema
En **Marca**, **Logo** sigue siendo la imagen predeterminada (tema claro y correo electrónico). **Logo para el tema oscuro (opcional)** acepta una segunda imagen PNG o JPG de hasta 512 KB, preparada para fondos oscuros. Aparece sin marco blanco en el menú lateral, la pantalla de acceso de la instalación y la vista previa. Sin la segunda imagen, el logo predeterminado conserva la protección blanca en el tema oscuro. Cada archivo se elimina por separado; quitar el oscuro restaura el comportamiento anterior.
Una organización que sube su propio logo predeterminado deja de heredar la imagen oscura de la instalación, evitando mezclar marcas. Sin logos propios, hereda el par de la instalación. El acceso siempre usa la marca de la instalación. [Compatibilidad y reversión](runbooks/logo-por-tema.md).
+6
View File
@@ -198,3 +198,9 @@ Guia completo de instalação: [`hostgator-setup-kit/README.md`](../hostgator-se
*Este documento existe em três idiomas, e o pré-requisito que a versão anterior deste rodapé nomeava foi pago: as traduções carregam na primeira linha um selo com o hash do original, e editar `docs/white-label.md` sem re-selar reprova `pnpm test:unit`. Depois de traduzir, re-sele com `pnpm exec tsx scripts/selar-traducao.ts --todas`.*
*Os três READMEs ficaram **fora** do selo de propósito. São o arquivo mais editado do repositório: com selo, cada conserto viraria um PR bloqueado até ~490 linhas serem re-traduzidas duas vezes — e o desfecho realista disso não é tradução em dia, é alguém re-selar sem traduzir, que é o único jeito de o selo morrer. Eles entram quando alguém quiser pagar esse custo de olhos abertos.*
## Logo para cada tema
Em **Marca**, o campo **Logo** continua sendo a imagem padrão (tema claro e mensagens por e-mail). O campo **Logo para o tema escuro (opcional)** aceita uma segunda imagem PNG ou JPG de até 512 KB, preparada para fundo escuro. Essa imagem aparece sem moldura branca no menu lateral, na tela de entrada da instalação e na prévia. Sem a segunda imagem, o logo padrão conserva a proteção branca no tema escuro. Cada arquivo tem sua própria remoção; remover o escuro restaura o comportamento anterior.
A organização que envia um logo padrão próprio deixa de herdar a arte escura da instalação: assim, os dois temas não mostram marcas diferentes por acidente. Sem logos próprios, herda o par da instalação. O login sempre usa a marca da instalação. [Compatibilidade e reversão](runbooks/logo-por-tema.md).
+5 -1
View File
@@ -203,5 +203,9 @@ export interface ActiveOrg {
* banco: `app/layout.tsx` resolve a pilha e o `<PublicEnvScript/>` a injeta em
* `window.__PUBLIC_ENV__`, de onde `branding()` a lê.
*/
marca?: { readonly nome?: string; readonly logoUrl?: string | null };
marca?: {
readonly nome?: string;
readonly logoUrl?: string | null;
readonly logoDarkUrl?: string | null;
};
}
+2
View File
@@ -23,6 +23,8 @@ export type Branding = {
name: string;
/** URL do logo, ou `null` quando a marca deve aparecer como texto. */
logoUrl: string | null;
/** Arte opcional preparada para o tema escuro; ausente preserva o logo padrão. */
logoDarkUrl?: string | null;
/** Primeira letra do nome — usada onde só cabe um caractere (sidebar recolhida). */
initial: string;
};
+1 -1
View File
@@ -102,7 +102,7 @@ export type SementeDoAmbiente = {
* 0155; tratá-la diferente agora criaria duas regras para o mesmo caso.
*/
const COLUNAS =
"app_name, logo_url, logo_path, accent_hex, show_powered_by, seeded_from_env, fallback_at, fallback_reason";
"app_name, logo_url, logo_path, logo_dark_path, accent_hex, show_powered_by, seeded_from_env, fallback_at, fallback_reason";
/**
* Códigos de recusa — os que significam "a cor configurada NÃO pintou".
+1
View File
@@ -84,6 +84,7 @@ export function marcaDaOrganizacaoDeSettings(settings: unknown): MarcaDaOrganiza
// não fosse string desceria para `logoDaCamada` e viraria uma URL montada a
// partir de um objeto.
logo_path: texto(envelope.logo_path),
...(texto(envelope.logo_dark_path) ? { logo_dark_path: texto(envelope.logo_dark_path) } : {}),
};
}
+39 -25
View File
@@ -27,13 +27,7 @@
import { resolveBranding, type Branding } from "@/lib/branding";
import {
derivarMarca,
type CodigoDeMotivo,
type Marca,
type Regua,
type Tema,
} from "./contraste";
import { derivarMarca, type CodigoDeMotivo, type Marca, type Regua, type Tema } from "./contraste";
import { logoDaCamada } from "./logo";
import { normalizarHex } from "./rampa";
import {
@@ -80,6 +74,7 @@ export type CamadaDeMarca = {
readonly origem: string;
readonly nome?: string | null;
readonly logoUrl?: string | null;
readonly logoDarkUrl?: string | null;
/**
* O envelope CRU, como veio da fonte — `unknown` de propósito: validar é
* trabalho do resolvedor, e uma camada que já entregasse validado esconderia
@@ -102,6 +97,7 @@ export type MarcaResolvida = Branding & {
readonly origens: {
readonly nome: string;
readonly logoUrl: string;
readonly logoDarkUrl?: string;
readonly cor: string;
};
readonly motivos: readonly MotivoDaMarca[];
@@ -197,11 +193,7 @@ function resolverCor(
regua: Regua,
): { cor: CorResolvida | null; motivos: MotivoDaMarca[] } {
const motivos: MotivoDaMarca[] = [];
const anotar = (
codigo: CodigoDaResolucao,
detalhe: string,
alvo: string | null = null,
) => {
const anotar = (codigo: CodigoDaResolucao, detalhe: string, alvo: string | null = null) => {
motivos.push({ codigo, origem, tema: null, alvo, detalhe });
};
@@ -222,9 +214,7 @@ function resolverCor(
// inclusive campo AUSENTE ou de tipo errado no mesmo caminho, é forma do
// envelope. Sem o `code`, um envelope sem `semente_hex` seria reportado como
// cor mal digitada, e o operador iria procurar o erro no lugar errado.
const noHex = lido.error.issues.some(
(i) => i.path[0] === "semente_hex" && i.code === "custom",
);
const noHex = lido.error.issues.some((i) => i.path[0] === "semente_hex" && i.code === "custom");
if (noHex) {
anotar("semente_invalida", "semente_hex não é um hex de cor (#rgb ou #rrggbb)");
} else {
@@ -295,10 +285,7 @@ function resolverCor(
// Não é `catch` que silencia: o motivo sai com a mensagem real (sem o hex).
// Existe porque a alternativa é a exceção subir até o layout e derrubar o
// produto inteiro por causa de uma cor.
anotar(
"derivacao_falhou",
semIdentidade(erro instanceof Error ? erro.message : String(erro)),
);
anotar("derivacao_falhou", semIdentidade(erro instanceof Error ? erro.message : String(erro)));
return { cor: { semente, papel, derivada: null }, motivos };
}
}
@@ -313,13 +300,21 @@ function resolverCor(
* anotada e a busca continua descendo. Numa instalação de revendedor, uma
* organização que grava lixo tem de cair na marca do revendedor, não na nossa.
*/
export function resolverMarca(
camadas: readonly CamadaDeMarca[],
regua: Regua,
): MarcaResolvida {
export function resolverMarca(camadas: readonly CamadaDeMarca[], regua: Regua): MarcaResolvida {
const nome = primeiroDefinido(camadas, (c) => c.nome);
const logo = primeiroDefinido(camadas, (c) => c.logoUrl);
const base = resolveBranding(nome?.valor, logo?.valor);
// Uma camada com logo próprio encerra a herança do par: a empresa não pode
// ganhar o logo escuro de OUTRA marca só porque enviou apenas o padrão.
let logoEscuro: { valor: string; origem: string } | undefined;
for (const camada of camadas) {
const escuro = camada.logoDarkUrl?.trim();
if (escuro) {
logoEscuro = { valor: escuro, origem: camada.origem };
break;
}
if (camada.logoUrl?.trim()) break;
}
const motivos: MotivoDaMarca[] = [];
let cor: CorResolvida | null = null;
@@ -338,10 +333,12 @@ export function resolverMarca(
return {
...base,
...(logoEscuro ? { logoDarkUrl: logoEscuro.valor } : {}),
cor,
origens: {
nome: nome?.origem ?? PADRAO,
logoUrl: logo?.origem ?? PADRAO,
...(logoEscuro ? { logoDarkUrl: logoEscuro.origem } : {}),
cor: origemDaCor,
},
motivos,
@@ -375,6 +372,7 @@ export type LinhaDaInstalacao = {
* então uma instalação pode voltar a rodar código que não conhece esta coluna.
*/
readonly logo_path?: string | null;
readonly logo_dark_path?: string | null;
readonly accent_hex?: string | null;
};
@@ -397,17 +395,24 @@ export function camadaDaInstalacao(linha: LinhaDaInstalacao | null): CamadaDeMar
const hex = (linha.accent_hex ?? "").trim();
// O arquivo subido vence a URL colada, DENTRO desta camada — ver `logoDaCamada`.
const logoUrl = logoDaCamada(linha.logo_path, linha.logo_url);
const logoDarkUrl = logoDaCamada(linha.logo_dark_path, null);
// Mesma regra do `.env`: campo vazio é ausência de configuração, não cor com
// defeito. Sem isto, uma linha semeada de um `.env` sem cor emitiria
// `cor_ausente` em toda instalação de fábrica — e aviso no caso normal ensina
// o operador a ignorar avisos.
if (hex.length === 0) {
return { origem: "banco", nome: linha.app_name, logoUrl };
return {
origem: "banco",
nome: linha.app_name,
logoUrl,
...(logoDarkUrl ? { logoDarkUrl } : {}),
};
}
return {
origem: "banco",
nome: linha.app_name,
logoUrl,
...(logoDarkUrl ? { logoDarkUrl } : {}),
cor: envelopeDeSemente(hex),
};
}
@@ -463,6 +468,7 @@ export type MarcaDaOrganizacao = {
* continua com o logo da instalação, pela precedência por campo.
*/
readonly logo_path?: string | null;
readonly logo_dark_path?: string | null;
};
/**
@@ -495,11 +501,19 @@ export function camadaDaOrganizacao(marca: MarcaDaOrganizacao | null): CamadaDeM
if (!marca) return { origem: "organizacao" };
const hex = (marca.accent_hex ?? "").trim();
const logoUrl = logoDaCamada(marca.logo_path, null);
if (hex.length === 0) return { origem: "organizacao", nome: marca.app_name, logoUrl };
const logoDarkUrl = logoDaCamada(marca.logo_dark_path, null);
if (hex.length === 0)
return {
origem: "organizacao",
nome: marca.app_name,
logoUrl,
...(logoDarkUrl ? { logoDarkUrl } : {}),
};
return {
origem: "organizacao",
nome: marca.app_name,
logoUrl,
...(logoDarkUrl ? { logoDarkUrl } : {}),
cor: envelopeDeSemente(hex),
};
}
+2
View File
@@ -66,6 +66,7 @@ export type MarcaDeSaida = {
* (`app/(public)/layout.tsx`).
*/
readonly logoUrl: string | null;
readonly logoDarkUrl?: string | null;
/** `#hex` sempre — o formato que cliente de e-mail e @react-pdf entendem. */
readonly accent: string;
/** Preto ou branco, já com o piso de contraste aplicado. */
@@ -198,6 +199,7 @@ export async function marcaDaSaida(organizationId: string | null): Promise<Marca
return {
nome: marca.name,
logoUrl: marca.logoUrl,
...(marca.logoDarkUrl ? { logoDarkUrl: marca.logoDarkUrl } : {}),
accent,
// Nunca `#ffffff` fixo: `melhorFrenteSobre` (`contraste.ts:79`) já
// decide preto ou branco pelo contraste real. Uma marca amarela colada
+3
View File
@@ -231,6 +231,9 @@ export const DICIONARIO: Traducoes = {
"Abordado": { es: "Contactado" },
"Não abordado": { es: "No contactado" },
"Respondeu": { es: "Respondió" },
"Logo para o tema escuro (opcional)": { es: "Logo para el tema oscuro (opcional)" },
"Remover logo escuro": { es: "Eliminar logo oscuro" },
"Use uma versão legível sobre fundo escuro. Ela aparece sem moldura branca. Sem ela, o logo padrão mantém a proteção de contraste. PNG ou JPG, até 512 KB.": { es: "Usa una versión legible sobre fondo oscuro. Se muestra sin marco blanco. Sin ella, el logo predeterminado conserva la protección de contraste. PNG o JPG, hasta 512 KB." },
// /admin/email — o servidor SMTP da instalação (PR #714, @betoarts, recorte).
"Servidor de e-mail conectado e autenticado.": { es: "Servidor de correo conectado y autenticado." },
"Preencha e salve o servidor e o remetente antes de testar.": {
+187
View File
@@ -37140,6 +37140,175 @@ end; $$;
revoke execute on function public.fn_service_inbound(uuid) from public,anon,authenticated;
grant execute on function public.fn_service_inbound(uuid) to service_role;
-- ---- logo por tema: funções (migration 0398) ----
create or replace function public.fn_definir_logo_por_tema_da_organizacao(
p_org uuid,
p_actor uuid,
p_path text,
p_tema text
) returns integer
language plpgsql
volatile
security definer
set search_path to 'public', 'pg_temp'
as $$
declare
v_linhas integer;
v_path text;
v_campo text;
begin
if p_org is null or p_actor is null then
raise exception 'logo_da_organizacao_argumento_nulo'
using errcode = '22023';
end if;
if p_tema is null or p_tema not in ('claro', 'escuro') then
raise exception 'logo_tema_invalido' using errcode = '22023';
end if;
v_campo := case when p_tema = 'escuro' then 'logo_dark_path' else 'logo_path' end;
v_path := nullif(btrim(coalesce(p_path, '')), '');
-- O PREFIXO ASSEVERADO DENTRO DO BANCO — o gate que sobrevive ao segundo
-- chamador. A rota monta o caminho a partir da organização resolvida do
-- cookie, mas "a rota monta certo" é promessa de UM chamador. Sem esta linha,
-- um caminho de outro escopo (o `platform/...` que qualquer pessoa lê no HTML
-- da tela de login) entraria como logo da organização — e o delete-on-replace
-- da rota, rodando como `service_role`, apagaria o logo da instalação inteira
-- na troca seguinte.
if v_path is not null
and v_path !~ ('^' || p_org::text || '/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg)$')
then
raise exception 'logo_da_organizacao_caminho_fora_do_escopo'
using errcode = '22023';
end if;
if not exists (
select 1 from public.user_organizations uo
where uo.user_id = p_actor
and uo.organization_id = p_org
and uo.role = 'admin'
and uo.revoked_at is null
)
and not exists (
select 1 from public.platform_admins pa
where pa.user_id = p_actor
and pa.revoked_at is null
)
then
raise exception 'logo_da_organizacao_sem_permissao'
using errcode = '42501';
end if;
-- Merge no CAMPO. `jsonb_set` direto em '{branding,logo_path}' NÃO serviria:
-- com `branding` ausente, `create_missing` só cria a ÚLTIMA chave e o caminho
-- intermediário faltando devolve o jsonb original intocado — silenciosamente.
update public.organizations o
set settings = case
when v_path is null
then jsonb_set(
coalesce(o.settings, '{}'::jsonb), '{branding}',
coalesce(o.settings -> 'branding', '{}'::jsonb) - v_campo, true)
else jsonb_set(
coalesce(o.settings, '{}'::jsonb), '{branding}',
coalesce(o.settings -> 'branding', '{}'::jsonb)
|| jsonb_build_object(v_campo, v_path), true)
end
where o.id = p_org;
get diagnostics v_linhas = row_count;
return v_linhas;
end;
$$;
create or replace function public.fn_definir_logo_da_organizacao(
p_org uuid, p_actor uuid, p_path text
) returns integer language sql volatile security invoker
set search_path to 'public', 'pg_temp'
as $$
select public.fn_definir_logo_por_tema_da_organizacao(p_org, p_actor, p_path, 'claro');
$$;
create or replace function public.fn_definir_marca_da_organizacao(
p_org uuid,
p_actor uuid,
p_marca jsonb
) returns integer
language plpgsql
volatile
security definer
set search_path to 'public', 'pg_temp'
as $$
declare
v_linhas integer;
v_hex text;
v_limpar boolean;
begin
if p_org is null or p_actor is null then
raise exception 'marca_da_organizacao_argumento_nulo'
using errcode = '22023';
end if;
v_limpar := p_marca is null or jsonb_typeof(p_marca) = 'null';
if not v_limpar and jsonb_typeof(p_marca) <> 'object' then
raise exception 'marca_da_organizacao_forma_invalida: %', jsonb_typeof(p_marca)
using errcode = '22023';
end if;
v_hex := nullif(p_marca ->> 'accent_hex', '');
if v_hex is not null and v_hex !~ '^#[0-9a-f]{6}$' then
raise exception 'marca_da_organizacao_accent_hex_invalido'
using errcode = '22023';
end if;
if not exists (
select 1 from public.user_organizations uo
where uo.user_id = p_actor
and uo.organization_id = p_org
and uo.role = 'admin'
and uo.revoked_at is null
)
and not exists (
select 1 from public.platform_admins pa
where pa.user_id = p_actor
and pa.revoked_at is null
)
then
raise exception 'marca_da_organizacao_sem_permissao'
using errcode = '42501';
end if;
-- Nome/cor não podem injetar nem apagar os arquivos, que têm rota própria.
update public.organizations o
set settings = case
when v_limpar
and coalesce(o.settings #>> '{branding,logo_path}', '') = ''
and coalesce(o.settings #>> '{branding,logo_dark_path}', '') = ''
then coalesce(o.settings, '{}'::jsonb) - 'branding'
else jsonb_set(
coalesce(o.settings, '{}'::jsonb), '{branding}',
(case when v_limpar then '{}'::jsonb
else p_marca - 'logo_path' - 'logo_dark_path' end)
|| jsonb_strip_nulls(jsonb_build_object(
'logo_path', o.settings #> '{branding,logo_path}',
'logo_dark_path', o.settings #> '{branding,logo_dark_path}'
)), true) end
where o.id = p_org;
get diagnostics v_linhas = row_count;
return v_linhas;
end;
$$;
revoke execute on function public.fn_definir_logo_por_tema_da_organizacao(uuid, uuid, text, text) from public, anon, authenticated;
grant execute on function public.fn_definir_logo_por_tema_da_organizacao(uuid, uuid, text, text) to service_role;
revoke execute on function public.fn_definir_logo_da_organizacao(uuid, uuid, text) from public, anon, authenticated;
grant execute on function public.fn_definir_logo_da_organizacao(uuid, uuid, text) to service_role;
revoke execute on function public.fn_definir_marca_da_organizacao(uuid, uuid, jsonb) from public, anon, authenticated;
grant execute on function public.fn_definir_marca_da_organizacao(uuid, uuid, jsonb) to service_role;
notify pgrst, 'reload schema';
-- ---- VARREDURA anon: função nova nasce exposta em quem ATUALIZA (migration 0116) ----
--
-- ⚠️ DE PROPÓSITO, NENHUMA FUNÇÃO É CRIADA DEPOIS DESTE BLOCO. Apêndice que cria
@@ -38019,3 +38188,21 @@ end $$;
-- a lista de erros benignos do update.sh, então a atualização não diz
-- "atualizado" com módulo fora do ar. Instalação nova não tem módulo: no-op.
do $f$ begin perform public.fn_conferir_modulos_instalados(); end $f$;
-- ---- logo por tema: coluna da instalação (migration 0398) ----
-- 0398 — Logo opcional para o tema escuro, preservando o logo padrão.
-- Aditiva: código anterior continua usando logo_path; rollback de imagem não
-- exige apagar coluna, arquivos ou dados. Somente a rota de logo escreve os caminhos.
alter table public.platform_branding add column if not exists logo_dark_path text;
update public.platform_branding set logo_dark_path = null
where logo_dark_path is not null
and logo_dark_path !~ '^platform/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg)$';
alter table public.platform_branding drop constraint if exists platform_branding_logo_dark_path;
alter table public.platform_branding add constraint platform_branding_logo_dark_path check (
logo_dark_path is null or
logo_dark_path ~ '^platform/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg)$'
);
comment on column public.platform_branding.logo_dark_path is
'Logo opcional para fundo escuro, sem moldura branca. Caminho em brand-logos; null conserva o comportamento do logo padrão.';
@@ -0,0 +1,183 @@
-- 0398 — Logo opcional para o tema escuro, preservando o logo padrão.
-- Aditiva: código anterior continua usando logo_path; rollback de imagem não
-- exige apagar coluna, arquivos ou dados. Somente a rota de logo escreve os caminhos.
alter table public.platform_branding add column if not exists logo_dark_path text;
update public.platform_branding set logo_dark_path = null
where logo_dark_path is not null
and logo_dark_path !~ '^platform/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg)$';
alter table public.platform_branding drop constraint if exists platform_branding_logo_dark_path;
alter table public.platform_branding add constraint platform_branding_logo_dark_path check (
logo_dark_path is null or
logo_dark_path ~ '^platform/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg)$'
);
comment on column public.platform_branding.logo_dark_path is
'Logo opcional para fundo escuro, sem moldura branca. Caminho em brand-logos; null conserva o comportamento do logo padrão.';
create or replace function public.fn_definir_logo_por_tema_da_organizacao(
p_org uuid,
p_actor uuid,
p_path text,
p_tema text
) returns integer
language plpgsql
volatile
security definer
set search_path to 'public', 'pg_temp'
as $$
declare
v_linhas integer;
v_path text;
v_campo text;
begin
if p_org is null or p_actor is null then
raise exception 'logo_da_organizacao_argumento_nulo'
using errcode = '22023';
end if;
if p_tema is null or p_tema not in ('claro', 'escuro') then
raise exception 'logo_tema_invalido' using errcode = '22023';
end if;
v_campo := case when p_tema = 'escuro' then 'logo_dark_path' else 'logo_path' end;
v_path := nullif(btrim(coalesce(p_path, '')), '');
-- O PREFIXO ASSEVERADO DENTRO DO BANCO — o gate que sobrevive ao segundo
-- chamador. A rota monta o caminho a partir da organização resolvida do
-- cookie, mas "a rota monta certo" é promessa de UM chamador. Sem esta linha,
-- um caminho de outro escopo (o `platform/...` que qualquer pessoa lê no HTML
-- da tela de login) entraria como logo da organização — e o delete-on-replace
-- da rota, rodando como `service_role`, apagaria o logo da instalação inteira
-- na troca seguinte.
if v_path is not null
and v_path !~ ('^' || p_org::text || '/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg)$')
then
raise exception 'logo_da_organizacao_caminho_fora_do_escopo'
using errcode = '22023';
end if;
if not exists (
select 1 from public.user_organizations uo
where uo.user_id = p_actor
and uo.organization_id = p_org
and uo.role = 'admin'
and uo.revoked_at is null
)
and not exists (
select 1 from public.platform_admins pa
where pa.user_id = p_actor
and pa.revoked_at is null
)
then
raise exception 'logo_da_organizacao_sem_permissao'
using errcode = '42501';
end if;
-- Merge no CAMPO. `jsonb_set` direto em '{branding,logo_path}' NÃO serviria:
-- com `branding` ausente, `create_missing` só cria a ÚLTIMA chave e o caminho
-- intermediário faltando devolve o jsonb original intocado — silenciosamente.
update public.organizations o
set settings = case
when v_path is null
then jsonb_set(
coalesce(o.settings, '{}'::jsonb), '{branding}',
coalesce(o.settings -> 'branding', '{}'::jsonb) - v_campo, true)
else jsonb_set(
coalesce(o.settings, '{}'::jsonb), '{branding}',
coalesce(o.settings -> 'branding', '{}'::jsonb)
|| jsonb_build_object(v_campo, v_path), true)
end
where o.id = p_org;
get diagnostics v_linhas = row_count;
return v_linhas;
end;
$$;
create or replace function public.fn_definir_logo_da_organizacao(
p_org uuid, p_actor uuid, p_path text
) returns integer language sql volatile security invoker
set search_path to 'public', 'pg_temp'
as $$
select public.fn_definir_logo_por_tema_da_organizacao(p_org, p_actor, p_path, 'claro');
$$;
create or replace function public.fn_definir_marca_da_organizacao(
p_org uuid,
p_actor uuid,
p_marca jsonb
) returns integer
language plpgsql
volatile
security definer
set search_path to 'public', 'pg_temp'
as $$
declare
v_linhas integer;
v_hex text;
v_limpar boolean;
begin
if p_org is null or p_actor is null then
raise exception 'marca_da_organizacao_argumento_nulo'
using errcode = '22023';
end if;
v_limpar := p_marca is null or jsonb_typeof(p_marca) = 'null';
if not v_limpar and jsonb_typeof(p_marca) <> 'object' then
raise exception 'marca_da_organizacao_forma_invalida: %', jsonb_typeof(p_marca)
using errcode = '22023';
end if;
v_hex := nullif(p_marca ->> 'accent_hex', '');
if v_hex is not null and v_hex !~ '^#[0-9a-f]{6}$' then
raise exception 'marca_da_organizacao_accent_hex_invalido'
using errcode = '22023';
end if;
if not exists (
select 1 from public.user_organizations uo
where uo.user_id = p_actor
and uo.organization_id = p_org
and uo.role = 'admin'
and uo.revoked_at is null
)
and not exists (
select 1 from public.platform_admins pa
where pa.user_id = p_actor
and pa.revoked_at is null
)
then
raise exception 'marca_da_organizacao_sem_permissao'
using errcode = '42501';
end if;
-- Nome/cor não podem injetar nem apagar os arquivos, que têm rota própria.
update public.organizations o
set settings = case
when v_limpar
and coalesce(o.settings #>> '{branding,logo_path}', '') = ''
and coalesce(o.settings #>> '{branding,logo_dark_path}', '') = ''
then coalesce(o.settings, '{}'::jsonb) - 'branding'
else jsonb_set(
coalesce(o.settings, '{}'::jsonb), '{branding}',
(case when v_limpar then '{}'::jsonb
else p_marca - 'logo_path' - 'logo_dark_path' end)
|| jsonb_strip_nulls(jsonb_build_object(
'logo_path', o.settings #> '{branding,logo_path}',
'logo_dark_path', o.settings #> '{branding,logo_dark_path}'
)), true) end
where o.id = p_org;
get diagnostics v_linhas = row_count;
return v_linhas;
end;
$$;
revoke execute on function public.fn_definir_logo_por_tema_da_organizacao(uuid, uuid, text, text) from public, anon, authenticated;
grant execute on function public.fn_definir_logo_por_tema_da_organizacao(uuid, uuid, text, text) to service_role;
revoke execute on function public.fn_definir_logo_da_organizacao(uuid, uuid, text) from public, anon, authenticated;
grant execute on function public.fn_definir_logo_da_organizacao(uuid, uuid, text) to service_role;
revoke execute on function public.fn_definir_marca_da_organizacao(uuid, uuid, jsonb) from public, anon, authenticated;
grant execute on function public.fn_definir_marca_da_organizacao(uuid, uuid, jsonb) to service_role;
notify pgrst, 'reload schema';
+2
View File
@@ -411,3 +411,5 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
| `20260923230000` | `0397_roteiro_encerra_com_humano_e_prazo` | **O roteiro de atendimento encerra quando um humano assume, no opt-out e no prazo (PR 2 do port do #1130, de @vgamkt; achados 9 e 5 da prova prática).** Gatilho `trg_contato_encerra_roteiro_com_humano_ou_opt_out` na virada false→true de `contacts.force_human` ou `contacts.is_blocked`: o `coletando` do contato vira `cancelled` com evento `roteiro_cancelado` (`humano_assumiu`/`opt_out`). Um lugar para todos os escritores dessas colunas (passagem do motor, ferramenta de handoff, orquestrador, teto de orçamento, ingestão do canal) — remendar cada um era a classe que o autor pagou cinco vezes. Encerrar, não pausar: retomar as perguntas depois de uma pessoa atender fala do que ela já tratou. A pausa curta por resposta manual pelo celular (`bot_silenced_until` com prazo) NÃO encerra: o roteiro só não roda com a IA calada. `fn_encerrar_roteiros_vencidos(p_limite)` (service_role) encerra o `coletando` sem mensagem lida há mais de `settings.expira_em_horas` do grafo (padrão 72 h), em lotes com `skip locked`, com evento `roteiro_expirado`; chamada pelo relógio do follow-up (`lib/relogio/executar.ts` e o cron `followup-flow-worker`). Funções novas com as origens de EXECUTE revogadas. Sem dado reescrito. Número 0397 e carimbo conferidos livres na main e nos PRs abertos (0393 #1527, 0396 #1561). |
| `20260923140000` | `0390_fotos_no_catalogo` | **O produto ganha foto, e o agente de IA manda a foto junto (ideia de @vgamkt, a partir do #1130; doc 62, opção c).** `catalog_products.fotos text[] not null default '{}'` com CHECK `cardinality(fotos) <= 5`: guarda CAMINHOS de Storage (`<org>/<produto>/<uuid>.<jpg\|png>`), na ordem da tela, e a primeira é a capa. **Coluna e não tabela:** pôr, tirar e reordenar é UM update da linha, e RLS (leitura da org, escrita `manager`+ da 0204) e cascata vêm da linha do produto. Bucket NOVO `catalog-photos`, **privado**, 5 MB, só `image/jpeg`/`image/png` (teto e formatos de imagem do WhatsApp oficial), sem policy em `storage.objects` — só o `service_role` escreve e lê, depois de a rota conferir o papel. Separado de `whatsapp-media` porque a limpeza de LGPD apaga a mídia das conversas: quando o agente manda a foto, o app a COPIA para a pasta da conversa, e é a cópia que a conversa possui. Quem LÊ um caminho da coluna (tela que assina, agente que copia, rota que apaga) confere o prefixo `<org>/<produto>/` — a coluna é gravável pelo PostgREST e a leitura é por service role. Aditiva e idempotente (`add column if not exists`, constraint recriada, bucket com `on conflict do update`); `imagem_url` (0204) fica intocada. |
| `20260923220000` | `0396_conversa_fica_com_quem_atendeu` | **"A conversa fica com quem atendeu" vira ajuste por empresa, DESLIGADO por padrão (ideia de @gustavorodcruz96, #1527; recorte da 0393 daquele PR, que não se reaproveita).** `fn_service_inbound` só reabre a conversa encerrada com o último atendente (`claimed`, IA calada, demanda nova com dono humano, `assigned_at` no instante da reabertura para o prazo de devolução automática contar do episódio novo) quando `organizations.settings.routing.conversation_stays_with_attendant` é o booleano `true` e o responsável ainda é membro agent+ da organização. Desligado ou ausente — o estado de toda empresa que já existe —, a função faz o mesmo que antes: reabre como `open`, sem dono, e o roteamento segue. A revisão do atendimento continua nova. Sem mudança de tabela ou backfill; função idempotente no baseline e nesta migration. |
| `20260924060000` | `0398_logo_por_tema` | Logo escuro opcional por instalação/organização; preserva o padrão e o isolamento dos arquivos. |
+106 -27
View File
@@ -131,19 +131,21 @@ function chunk(tipo: string, dados: Buffer): Buffer {
return Buffer.concat([tamanho, corpo, crc]);
}
function pngSolido(lado: number, cor: [number, number, number]): Buffer {
function pngSolido(lado: number, cor: [number, number, number], bordaTransparente = false): Buffer {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(lado, 0);
ihdr.writeUInt32BE(lado, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // color type: truecolor
ihdr[9] = 6; // truecolor + alpha: a fixture também mede transparência real.
const linhas: Buffer[] = [];
for (let y = 0; y < lado; y++) {
const linha = Buffer.alloc(1 + lado * 3);
const linha = Buffer.alloc(1 + lado * 4);
for (let x = 0; x < lado; x++) {
linha[1 + x * 3] = cor[0];
linha[2 + x * 3] = cor[1];
linha[3 + x * 3] = cor[2];
linha[1 + x * 4] = cor[0];
linha[2 + x * 4] = cor[1];
linha[3 + x * 4] = cor[2];
linha[4 + x * 4] =
bordaTransparente && (x < 12 || y < 12 || x >= lado - 12 || y >= lado - 12) ? 0 : 255;
}
linhas.push(linha);
}
@@ -297,9 +299,7 @@ async function loginComTotp(page: Page, email: string, secret: string): Promise<
]);
if (desfecho === "entrou") return;
if (desfecho === "sem-desfecho") {
throw new Error(
`o desafio de MFA de ${email} não terminou em 60s (url=${page.url()})`,
);
throw new Error(`o desafio de MFA de ${email} não terminou em 60s (url=${page.url()})`);
}
await page.waitForTimeout(msUntilNextTotpWindow() + 200);
}
@@ -310,6 +310,7 @@ async function subir(
page: Page,
escopo: Escopo,
arquivo: { nome: string; mime: string; bytes: Buffer },
tema: "claro" | "escuro" = "claro",
): Promise<void> {
// A HIDRATAÇÃO, e não a visibilidade: o input existe no HTML do SSR antes de o
// React atar o `onChange`, e arquivo posto nessa janela não dispara requisição
@@ -318,22 +319,33 @@ async function subir(
page.locator(`[data-campo-de-logo='${escopo}'][data-hidratado]`),
`o campo de logo da camada "${escopo}" não hidratou`,
).toBeVisible({ timeout: 15_000 });
await page.locator(`#logo-${escopo}`).setInputFiles({
const entrada = tema === "escuro" ? `#logo-escuro-${escopo}` : `#logo-${escopo}`;
const resposta = page.waitForResponse(
(r) => r.url().includes("/api/v1/marca/logo") && r.request().method() === "POST",
);
await page.locator(entrada).setInputFiles({
name: arquivo.nome,
mimeType: arquivo.mime,
buffer: arquivo.bytes,
});
await expect(page.getByText(/logo atualizado/i)).toBeVisible({ timeout: 15_000 });
expect((await resposta).ok()).toBe(true);
await expect(page.getByText(/logo atualizado/i).last()).toBeVisible({ timeout: 15_000 });
}
async function removerLogoSeHouver(page: Page, tela: string, escopo: Escopo): Promise<void> {
await page.goto(tela);
const remover = page
.locator(`[data-campo-de-logo='${escopo}']`)
.getByRole("button", { name: /^remover$/i });
if ((await remover.count()) === 0) return;
await remover.click();
await expect(page.getByText(/logo removido/i)).toBeVisible({ timeout: 15_000 });
const campo = page.locator(`[data-campo-de-logo='${escopo}'][data-hidratado]`);
await expect(campo).toBeVisible();
for (const nome of [/^remover logo escuro$/i, /^remover$/i]) {
const remover = campo.getByRole("button", { name: nome });
if ((await remover.count()) === 0) continue;
const resposta = page.waitForResponse(
(r) => r.url().includes("/api/v1/marca/logo") && r.request().method() === "DELETE",
);
await remover.click();
expect((await resposta).ok()).toBe(true);
await expect(remover).toHaveCount(0);
}
}
function evidencia(nome: string): string {
@@ -401,9 +413,10 @@ test.describe("a moldura do logo no tema escuro", () => {
`a moldura não foi pintada: o pai do <img> tem background-color=${m.fundo} ` +
`(tag=${m.tagDoPai}, classe="${m.classeDoPai}")`,
).toBe(true);
expect(m.padding.every((p) => p > 0), `a moldura não tem folga: padding=${m.padding}`).toBe(
true,
);
expect(
m.padding.every((p) => p > 0),
`a moldura não tem folga: padding=${m.padding}`,
).toBe(true);
expect(m.sombra, "a moldura não tem sombra").not.toBe("none");
// CONTENÇÃO, não proximidade: a moldura tem de ser MAIOR que o logo nos dois
@@ -443,10 +456,7 @@ test.describe("a moldura do logo no tema escuro", () => {
const contexto = await browser.newContext();
try {
const pagina = await contexto.newPage();
await pagina.addInitScript(
(t) => window.localStorage.setItem("deskcomm-theme", t),
tema,
);
await pagina.addInitScript((t) => window.localStorage.setItem("deskcomm-theme", t), tema);
await pagina.goto("/login");
expect(await temaDaPagina(pagina), `a fachada não ficou em ${tema}`).toBe(tema);
@@ -460,9 +470,10 @@ test.describe("a moldura do logo no tema escuro", () => {
`a fachada no escuro desenhou o logo CRU (fundo=${m.fundo}) — o defeito ` +
`volta inteiro na tela de primeira impressão`,
).toBe(true);
expect(m.padding.every((p) => p > 0), `fachada escura sem folga: ${m.padding}`).toBe(
true,
);
expect(
m.padding.every((p) => p > 0),
`fachada escura sem folga: ${m.padding}`,
).toBe(true);
expect(m.caixaDoPai.largura).toBeGreaterThan(m.caixaDoLogo.largura);
expect(m.caixaDoPai.altura).toBeGreaterThan(m.caixaDoLogo.altura);
} else {
@@ -584,6 +595,74 @@ test.describe("a moldura do logo no tema escuro", () => {
expect(escuro.y, "o cabeçalho saiu do topo da barra").toBe(0);
});
test("(7) dois logos: troca real de tema, fachada e remoção independente", async ({
page,
browser,
}) => {
await loginComTotp(page, creds.users.dono!.email, secret());
await removerLogoSeHouver(page, "/app/settings/marca", "organizacao");
await removerLogoSeHouver(page, "/admin/marca", "instalacao");
const clara = { nome: "logo-claro.png", mime: "image/png", bytes: PNG_AZUL_MARINHO };
const escura = {
nome: "logo-escuro.png",
mime: "image/png",
bytes: pngSolido(64, [255, 255, 255], true),
};
await subir(page, "instalacao", clara);
await subir(page, "instalacao", escura, "escuro");
const previaClara = page.locator("[data-previa-do-logo='claro'] img");
const previaEscura = page.locator("[data-previa-do-logo='escuro'] img");
await expect(previaEscura).toBeVisible();
const urlClara = await previaClara.getAttribute("src");
const urlEscura = await previaEscura.getAttribute("src");
expect(urlEscura).toBeTruthy();
expect(urlEscura).not.toBe(urlClara);
expect(fundoETransparente((await medirMoldura(previaEscura)).fundo)).toBe(true);
await page.screenshot({ path: evidencia("7-duas-artes-previa.png"), fullPage: true });
await page.goto("/app/inbox");
for (const tema of ["dark", "light"] as const) {
await escolherTemaPelaTela(page, tema);
const logo = page.locator("aside img:visible").first();
await expect(logo).toHaveAttribute("src", (tema === "dark" ? urlEscura : urlClara)!);
expect(fundoETransparente((await medirMoldura(logo)).fundo)).toBe(true);
await page.screenshot({ path: evidencia(`7-barra-${tema}.png`) });
const contexto = await browser.newContext();
try {
const fachada = await contexto.newPage();
await fachada.addInitScript((t) => localStorage.setItem("deskcomm-theme", t), tema);
await fachada.goto("/login");
const logoPublico = fachada.getByTestId(
tema === "dark" ? "logo-escuro-da-fachada" : "logo-da-fachada",
);
await expect(logoPublico).toHaveAttribute("src", (tema === "dark" ? urlEscura : urlClara)!);
expect(fundoETransparente((await medirMoldura(logoPublico)).fundo)).toBe(true);
await fachada.screenshot({ path: evidencia(`7-login-${tema}.png`) });
} finally {
await contexto.close();
}
}
// Remover só o segundo arquivo deve recuperar o chip anterior sem apagar o primeiro.
await page.goto("/admin/marca");
const resposta = page.waitForResponse(
(r) => r.url().includes("tema=escuro") && r.request().method() === "DELETE",
);
await page.getByRole("button", { name: "Remover logo escuro", exact: true }).click();
expect((await resposta).ok()).toBe(true);
await expect(page.locator("[data-previa-do-logo='escuro'] img")).toHaveAttribute(
"src",
urlClara!,
);
expect(
fundoEClaro((await medirMoldura(page.locator("[data-previa-do-logo='escuro'] img"))).fundo),
).toBe(true);
await page.reload();
await expect(page.locator("[data-previa-do-logo='claro'] img")).toHaveAttribute(
"src",
urlClara!,
);
});
/**
* A restauração. Num `afterAll` porque ele roda mesmo quando um caso estoura —
* medido: com A→B(estouro)→C, a ordem observada é ["A","B","afterAll"], ou
+61
View File
@@ -0,0 +1,61 @@
/** 0398: isolamento de tenant, autorização, escrita por tema e rollback do cliente antigo. */
import { beforeAll, describe, expect, it } from "vitest";
import { lastLine, sql } from "./gov-helpers";
const org = "10c00398-0000-4000-8000-000000000001";
const outra = "10c00398-0000-4000-8000-000000000002";
const admin = "10c00398-1111-4000-8000-000000000001";
const estranho = "10c00398-1111-4000-8000-000000000002";
const arquivo = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.png";
const path = `${org}/${arquivo}`;
const fn = "public.fn_definir_logo_por_tema_da_organizacao";
const gravar = (o = org, a = admin, p: string | null = path, tema = "escuro") =>
sql(`select ${fn}('${o}','${a}',${p === null ? "null" : `'${p}'`},'${tema}');`);
const ler = () =>
JSON.parse(
lastLine(sql(`select settings->'branding' from public.organizations where id='${org}';`)),
);
beforeAll(() => {
sql(`insert into auth.users(id,email) values ('${admin}','logo398a@invariant.test'),('${estranho}','logo398b@invariant.test');
insert into public.organizations(id,slug,legal_name,display_name) values ('${org}','logo398a','A','A'),('${outra}','logo398b','B','B');
insert into public.user_organizations(user_id,organization_id,role,accepted_at) values ('${admin}','${org}','admin',now()),('${estranho}','${outra}','admin',now());`);
});
describe("logo por tema no banco", () => {
it("grava escuro sem perder claro e salvar nome/cor preserva ambos", () => {
sql(`select public.fn_definir_logo_da_organizacao('${org}','${admin}','${path}');`);
gravar();
sql(
`select public.fn_definir_marca_da_organizacao('${org}','${admin}','{"app_name":"Nova marca","logo_dark_path":"injetado"}'::jsonb);`,
);
expect(ler()).toMatchObject({ logo_path: path, logo_dark_path: path, app_name: "Nova marca" });
sql(`select public.fn_definir_marca_da_organizacao('${org}','${admin}',null);`);
expect(ler()).toEqual({ logo_path: path, logo_dark_path: path });
});
it("remoção escura mantém padrão", () => {
gravar();
gravar(org, admin, null);
expect(ler().logo_dark_path).toBeUndefined();
expect(ler().logo_path).toBe(path);
});
it("recusa ator de outra organização", () => expect(() => gravar(org, estranho)).toThrow());
it("recusa caminho de outra organização e da instalação", () => {
expect(() => gravar(org, admin, `${outra}/${arquivo}`)).toThrow();
expect(() => gravar(org, admin, `platform/${arquivo}`)).toThrow();
});
it("recusa tema desconhecido", () =>
expect(() => gravar(org, admin, path, "inventado")).toThrow());
it("não concede execução a anon nem authenticated", () => {
for (const role of ["anon", "authenticated"])
expect(
lastLine(
sql(`select has_function_privilege('${role}','${fn}(uuid,uuid,text,text)','EXECUTE');`),
),
).toBe("f");
});
it("coluna da instalação recusa caminho de tenant", () => {
expect(() =>
sql(
`insert into public.platform_branding(id,logo_dark_path) values(1,'${path}') on conflict(id) do update set logo_dark_path=excluded.logo_dark_path;`,
),
).toThrow();
});
});
@@ -3,7 +3,7 @@
*
* ═══ O DEFEITO ═══
*
* O produto aceita UM logo só — `platform_branding.logo_url` /
* Quando só o logo padrão foi configurado, o produto usa — `platform_branding.logo_url` /
* `organizations.settings.branding` —, e não há segunda arte para o tema
* escuro. A arte que o operador sobe é, quase sempre, pensada para fundo
* claro. O `--color-surface` do tema escuro é `#1d1c17`: um logo azul-marinho
@@ -105,7 +105,7 @@ describe("o logo do operador não some no tema escuro", () => {
expect(
fonte,
"a prévia da aparência escura voltou a mostrar o logo cru — ela deixa de prever o que o app desenha",
).toMatch(/Apar.ncia escura["')\s]*\s*\?\s*["'`][^"'`]*bg-white/);
).toMatch(/Apar.ncia escura["')\s]*\s*&&\s*!escuroEmVigor\s*\?\s*["'`][^"'`]*bg-white/);
});
it("CONTROLE: as três superfícies continuam sendo as três que desenham o logo do operador", () => {
+127
View File
@@ -0,0 +1,127 @@
// @vitest-environment node
/** A rota real escolhe o campo, conserva o default legado e recusa tema antes de escrever. */
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({
user: { id: "11111111-1111-4111-8111-111111111111", is_platform_admin: false },
role: "admin",
rpc: vi.fn(),
upsert: vi.fn(),
upload: vi.fn(),
remove: vi.fn(),
audit: vi.fn(),
eq: vi.fn(),
}));
const org = "22222222-2222-4222-8222-222222222222";
vi.mock("@/lib/auth/server", () => ({
loadAuthUser: async () => mocks.user,
resolveActiveOrg: async () => ({
orgId: "22222222-2222-4222-8222-222222222222",
role: mocks.role,
}),
mfaEmDivida: async () => false,
}));
vi.mock("@/lib/impersonate/support", () => ({ requireSupportWrite: async () => null }));
vi.mock("@/lib/ai/dispatcher/rate-limit", () => ({
checkRateLimit: async () => ({ allowed: true }),
}));
vi.mock("@/lib/audit", () => ({ audit: mocks.audit }));
vi.mock("@/lib/branding/instalacao", () => ({ invalidarMarcaDaInstalacao: vi.fn() }));
vi.mock("@/lib/supabase/admin", () => ({
createAdminClient: () => ({
rpc: mocks.rpc,
from: () => ({
select: () => ({
eq: (...args: unknown[]) => {
mocks.eq(...args);
return { maybeSingle: async () => ({ data: { settings: { branding: {} } } }) };
},
}),
upsert: mocks.upsert,
}),
storage: { from: () => ({ upload: mocks.upload, remove: mocks.remove }) },
}),
}));
import { POST, DELETE } from "@/app/api/v1/marca/logo/route";
beforeEach(() => {
vi.clearAllMocks();
mocks.user.is_platform_admin = false;
mocks.role = "admin";
mocks.rpc.mockResolvedValue({ data: 1, error: null });
mocks.upsert.mockResolvedValue({ error: null });
mocks.upload.mockResolvedValue({ error: null });
mocks.remove.mockResolvedValue({ error: null });
mocks.audit.mockResolvedValue(undefined);
});
function post(tema?: string, escopo = "organizacao") {
const form = new FormData();
form.set("escopo", escopo);
if (tema !== undefined) form.set("tema", tema);
form.set(
"file",
new File(
[
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg==",
"base64",
),
],
"logo.png",
{ type: "image/png" },
),
);
return POST(
new NextRequest("http://localhost/api/v1/marca/logo", { method: "POST", body: form }),
);
}
describe("upload por tema", () => {
it("omitir tema continua gravando logo claro", async () => {
expect((await post()).status).toBe(200);
expect(mocks.rpc).toHaveBeenCalledWith(
"fn_definir_logo_por_tema_da_organizacao",
expect.objectContaining({ p_org: org, p_tema: "claro" }),
);
});
it("escuro grava somente o campo da instalação e audita o tema", async () => {
mocks.user.is_platform_admin = true;
expect((await post("escuro", "instalacao")).status).toBe(200);
expect(mocks.upsert).toHaveBeenCalledWith(
expect.objectContaining({ id: 1, logo_dark_path: expect.stringMatching(/^platform\//) }),
{ onConflict: "id" },
);
expect(mocks.upsert.mock.calls[0]![0]).not.toHaveProperty("logo_path");
expect(mocks.audit).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({ fields_changed: ["logo_dark_path"], tema: "escuro" }),
}),
);
});
it("recusa tema desconhecido antes de upload, banco ou remoção", async () => {
expect((await post("inventado")).status).toBe(422);
expect(mocks.upload).not.toHaveBeenCalled();
expect(mocks.rpc).not.toHaveBeenCalled();
expect(mocks.remove).not.toHaveBeenCalled();
});
it("recusa trocar logo da instalação por admin de organização", async () => {
expect((await post("escuro", "instalacao")).status).toBe(403);
expect(mocks.upload).not.toHaveBeenCalled();
});
it("viewer não pode trocar o logo escuro", async () => {
mocks.role = "viewer";
expect((await post("escuro")).status).toBe(403);
expect(mocks.upload).not.toHaveBeenCalled();
});
it("remover escuro usa só o tenant da sessão e o tema pedido", async () => {
const response = await DELETE(
new NextRequest("http://localhost/api/v1/marca/logo?escopo=organizacao&tema=escuro"),
);
expect(response.status).toBe(200);
expect(mocks.eq).toHaveBeenCalledWith("id", org);
expect(mocks.rpc).toHaveBeenCalledWith("fn_definir_logo_por_tema_da_organizacao", {
p_org: org,
p_actor: mocks.user.id,
p_path: null,
p_tema: "escuro",
});
});
});
+91
View File
@@ -0,0 +1,91 @@
/** Dois logos sem perder o fallback, a herança ou a prévia após upload. */
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { CampoDeLogo } from "@/components/branding/CampoDeLogo";
import { resolverMarca } from "@/lib/branding/resolve";
import { REGUA_DO_PRODUTO } from "@/lib/branding/regua-do-produto";
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const clara = "https://example.test/claro.png";
const escura = "https://example.test/escuro.png";
const imagem = (tema: string) =>
document.querySelector<HTMLImageElement>(`[data-previa-do-logo='${tema}'] img`)!;
function campo(dark: string | null = null) {
return render(
<CampoDeLogo
escopo="organizacao"
logoDaCamada={{ url: clara, escuraUrl: dark }}
logoHerdado={null}
origemDoHerdado="da instalação"
nomeEmVigor="Empresa"
/>,
);
}
describe("par de logos e herança", () => {
it("herda os dois logos quando a organização não define nenhum", () => {
const m = resolverMarca(
[{ origem: "organizacao" }, { origem: "banco", logoUrl: clara, logoDarkUrl: escura }],
REGUA_DO_PRODUTO,
);
expect(m.logoUrl).toBe(clara);
expect(m.logoDarkUrl).toBe(escura);
});
it("logo próprio da organização bloqueia arte escura da outra marca", () => {
const m = resolverMarca(
[
{ origem: "organizacao", logoUrl: clara },
{ origem: "banco", logoUrl: "/outra.png", logoDarkUrl: escura },
],
REGUA_DO_PRODUTO,
);
expect(m.logoUrl).toBe(clara);
expect(m.logoDarkUrl).toBeUndefined();
});
it("permite substituir somente a arte escura", () => {
const m = resolverMarca(
[
{ origem: "organizacao", logoDarkUrl: escura },
{ origem: "banco", logoUrl: clara },
],
REGUA_DO_PRODUTO,
);
expect(m.logoUrl).toBe(clara);
expect(m.logoDarkUrl).toBe(escura);
expect(m.origens.logoDarkUrl).toBe("organizacao");
});
it("sem arte escura mantém o logo padrão protegido", () => {
campo();
expect(imagem("escuro").src).toBe(clara);
expect(imagem("escuro").parentElement!.className).toContain("bg-white");
});
it("arte escura preserva transparência e não altera a prévia clara", () => {
campo(escura);
expect(imagem("claro").src).toBe(clara);
expect(imagem("escuro").src).toBe(escura);
expect(imagem("escuro").parentElement!.className).not.toContain("bg-white");
});
it("upload escuro aparece mesmo se refresh não chegar; remover restaura o padrão", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { logo_url: escura } }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { logo_url: null } }) });
vi.stubGlobal("fetch", fetchMock);
campo();
fireEvent.change(screen.getByLabelText("Logo para o tema escuro (opcional)"), {
target: { files: [new File(["png"], "logo.png", { type: "image/png" })] },
});
await waitFor(() => expect(imagem("escuro").src).toBe(escura));
const form = fetchMock.mock.calls[0]![1].body as FormData;
expect(form.get("tema")).toBe("escuro");
expect(form.get("escopo")).toBe("organizacao");
expect(imagem("claro").src).toBe(clara);
fireEvent.click(screen.getByText("Remover logo escuro"));
await waitFor(() => expect(imagem("escuro").src).toBe(clara));
expect(fetchMock.mock.calls[1]![0]).toContain("tema=escuro");
expect(imagem("escuro").parentElement!.className).toContain("bg-white");
});
});