Files
DeskcommCRM/components/auth/MfaEnrollModal.tsx
T
Rafael César Rocha MonteiroandClaude Opus 5 72d2ed4c35 build(tailwind): migra para o Tailwind 4 com config em CSS
O `tailwind.config.ts` deixa de existir. O que era `theme.extend` virou um
bloco `@theme inline` em `app/globals.css` — o modo `inline` é o que preserva o
tema, porque o utilitário recebe o `var(--color-…)` literal em vez de uma cópia
do valor claro. Os três blocos de token (`:root`, `[data-theme="light"]`,
`[data-theme="dark"]`) ficam exatamente onde estavam: `extrairRegua`
(lib/branding/contraste.ts) acha o bloco claro por igualdade EXATA de seletor, e
`branding-tema-claro-escopavel` compara prop a prop os blocos claro e escuro.

O que a migração destapou, e é o item de maior impacto visual:

  No v3, modificador de opacidade sobre cor declarada como `var(--…)` sem
  `<alpha-value>` fazia o Tailwind NÃO EMITIR a regra — em silêncio. Todo token
  deste produto é `var(--color-*)`, então 62 classes distintas em 252 usos eram
  letra morta: `bg-destructive/10` não pintava fundo nenhum, `border-destructive/30`
  caía na cor neutra da regra global de borda, `text-muted-foreground/60` herdava
  a cor do pai. O v4 resolve opacidade por `color-mix()` e as 252 passaram a
  pintar. Medido contra um `tailwindcss@3.4.19` de descarte: alimentado com 7
  classes, ele emitiu 4 — nenhuma das 3 com barra.

Renomes mecânicos (58 `rounded` puro → `rounded-md`, 22 `outline-none` →
`outline-hidden`, 4 `flex-shrink-0` → `shrink-0`). O `rounded` puro não podia
ficar: o v3 remapeava o DEFAULT para `--radius-md` (8px) e o v4 tem um `rounded`
embutido de 0.25rem que nem `@utility` sobrescreve — o raio encolheria pela
metade em 33 arquivos, sem erro nenhum.

Guardas novas em `tests/unit/tailwind-tokens.test.ts`, porque a migração troca um
arquivo que o TypeScript conferia por um que ninguém confere: reprova config em
JS de volta, `:root` embrulhado em `@layer` (que ligaria a auto-referência que o
`@theme inline` emite), token referenciado que não existe, pasta de UI fora do
`@source`, e a volta dos três utilitários renomeados.

`branding-rampa` fatiava o CSS com `indexOf('[data-theme="dark"]')` e quebrou:
o `@custom-variant` pôs essa string no topo do arquivo. Passa a recortar o
`:root` por casamento de chaves.

Provado na tela, em instalação fresca (baseline.sql + bootstrap-owner + Supabase
local), medindo por `getComputedStyle` e nunca a olho — onboarding completo em
claro e escuro, 0 erro de console. Sonda em `tests/sonda-tailwind-4.ts`,
capturas e medições em `evidence/tailwind-4/`.

NÃO verificado: o build da imagem Docker. Os binários musl de `@tailwindcss/oxide`
e `lightningcss` estão no lockfile para linux x64 e arm64, mas docker.io está
inalcançável nesta máquina — quem prova é o `imagens-ok` no PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 01:48:00 -03:00

212 lines
7.5 KiB
TypeScript

"use client";
import { useEffect, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { TOTPInput } from "@/components/auth/TOTPInput";
import { RecoveryCodesPanel } from "@/components/auth/RecoveryCodesPanel";
import { enrollMfa } from "@/app/actions/auth/enrollMfa";
import { confirmMfaEnroll } from "@/app/actions/auth/confirmMfaEnroll";
type Step = "intro" | "scan" | "codes";
interface EnrollState {
factor_id: string;
qr_data_url: string;
uri: string;
secret: string;
}
/**
* Three-step MFA enrollment:
* 1. Intro / start
* 2. QR scan + 6-digit code verification
* 3. Recovery codes display + acknowledgement
*
* On completion, reloads the page so the parent layout re-evaluates the gate.
*/
export function MfaEnrollModal({ motivo = "obrigatorio" }: { motivo?: "obrigatorio" | "escolha" } = {}) {
const [step, setStep] = useState<Step>("intro");
const [enrollState, setEnrollState] = useState<EnrollState | null>(null);
const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [isPending, startTransition] = useTransition();
// Auto-start enrollment when entering scan step.
useEffect(() => {
if (step !== "scan" || enrollState) return;
startTransition(async () => {
const res = await enrollMfa();
if (!res.ok) {
setError(res.message ?? "Não foi possível iniciar a configuração.");
setStep("intro");
return;
}
setEnrollState({
factor_id: res.factor_id,
qr_data_url: res.qr_data_url,
uri: res.uri,
secret: res.secret,
});
});
}, [step, enrollState]);
const submitCode = (codeArg?: string) => {
if (!enrollState) return;
const finalCode = codeArg ?? code;
if (finalCode.length !== 6) return;
setError(null);
startTransition(async () => {
const res = await confirmMfaEnroll(finalCode, enrollState.factor_id);
if (!res.ok) {
if (res.error === "verify_failed" || res.error === "invalid_code") {
setError("Código inválido. Tente novamente.");
} else {
setError(res.message ?? "Falha ao confirmar. Tente novamente.");
}
setCode("");
return;
}
setRecoveryCodes(res.recovery_codes);
setStep("codes");
});
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="mfa-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
>
<div className="w-full max-w-md rounded-lg border border-border bg-background p-6 shadow-xl">
{step === "intro" && (
<div className="space-y-4">
<div>
<h2 id="mfa-title" className="text-xl font-semibold">
Configure a verificação em duas etapas
</h2>
{/*
⚠️ O MESMO MODAL ATENDE DOIS MOMENTOS OPOSTOS. Ele nasceu dentro
do bloqueador de tela cheia, onde "sua conta exige" era verdade;
reusá-lo no botão "Ativar" de Configurações fazia a tela afirmar
uma obrigação que não existe — a pessoa está ligando a proteção
por vontade própria. E "2FA" é jargão: o resto do produto diz
"verificação em duas etapas".
*/}
<p className="mt-1 text-sm text-muted-foreground">
{motivo === "obrigatorio"
? "Esta empresa exige a verificação em duas etapas de quem administra. "
: "A cada login, além da senha, o sistema vai pedir um código de 6 dígitos. "}
Use um aplicativo autenticador (Google Authenticator, 1Password, Authy,
Bitwarden) para gerar os códigos.
</p>
</div>
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<Button
type="button"
className="w-full"
onClick={() => {
setError(null);
setStep("scan");
}}
disabled={isPending}
>
Iniciar configuração
</Button>
</div>
)}
{step === "scan" && (
<div className="space-y-5">
<div>
<h2 id="mfa-title" className="text-xl font-semibold">
Escaneie o QR code
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Abra seu app autenticador, adicione uma nova conta e digite o
código de 6 dígitos abaixo.
</p>
</div>
{!enrollState ? (
<div className="flex h-60 items-center justify-center text-sm text-muted-foreground">
Gerando QR code...
</div>
) : (
<>
<div className="flex justify-center">
{/* eslint-disable-next-line @next/next/no-img-element -- inline data URL, no benefit from next/image optimization */}
<img
src={enrollState.qr_data_url}
alt="QR code para configurar autenticador"
width={240}
height={240}
className="rounded-md border border-border bg-white p-2"
/>
</div>
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">
Não consegue escanear? Digite o código manual
</summary>
<code className="mt-2 block break-all rounded-md bg-muted p-2 font-mono">
{enrollState.secret}
</code>
</details>
<div className="space-y-3">
<p className="text-center text-sm font-medium">
Digite o código de 6 dígitos
</p>
<TOTPInput
value={code}
onChange={setCode}
onComplete={(c) => submitCode(c)}
disabled={isPending}
autoFocus
hasError={!!error}
/>
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-center text-sm text-destructive">
{error}
</div>
)}
<Button
type="button"
className="w-full"
disabled={isPending || code.length !== 6}
onClick={() => submitCode()}
>
{isPending ? "Verificando..." : "Confirmar"}
</Button>
</div>
</>
)}
</div>
)}
{step === "codes" && recoveryCodes && (
<div className="space-y-4">
<div>
<h2 id="mfa-title" className="text-xl font-semibold">
Códigos de recuperação
</h2>
</div>
<RecoveryCodesPanel
codes={recoveryCodes}
onAcknowledge={() => {
window.location.reload();
}}
/>
</div>
)}
</div>
</div>
);
}