diff --git a/db/ESTRUTURA.md b/db/ESTRUTURA.md index f80bd74f..7ec05bfe 100644 --- a/db/ESTRUTURA.md +++ b/db/ESTRUTURA.md @@ -112,7 +112,7 @@ _Identidade, acesso, projeto/cliente e infraestrutura transversal._ | [`import_logs`](#import_logs) | 10 | id | ✅ | 3 | 0 | 2 | 1 | | [`invites`](#invites) | 9 | id | ✅ | 1 | 0 | 3 | 2 | | [`notifications`](#notifications) | 9 | id | ✅ | 3 | 0 | 3 | 8 | -| [`profiles`](#profiles) | 13 | id | ✅ | 5 | 1 | 2 | 76 | +| [`profiles`](#profiles) | 13 | id | ✅ | 5 | 1 | 2 | 77 | | [`project_members`](#project_members) | 6 | id | ✅ | 2 | 0 | 5 | 33 | | [`project_notifications`](#project_notifications) | 12 | id | ✅ | 4 | 0 | 3 | 4 | | [`projects`](#projects) | 38 | id | ✅ | 9 | 1 | 9 | 109 | @@ -568,7 +568,7 @@ _Tabelas do produto Lite — namespace próprio, não se mistura com o principal ### profiles -**Domínio:** Núcleo da plataforma · **RLS:** ✅ 5 policies · **Usos em `src/`:** 76 +**Domínio:** Núcleo da plataforma · **RLS:** ✅ 5 policies · **Usos em `src/`:** 77 | Coluna | Tipo | Nulo | Padrão | |---|---|:-:|---| diff --git a/src/app/alterar-senha/page.tsx b/src/app/alterar-senha/page.tsx index 236f98be..1e5a1133 100644 --- a/src/app/alterar-senha/page.tsx +++ b/src/app/alterar-senha/page.tsx @@ -1,10 +1,52 @@ "use client"; -import { useState } from "react"; -import { Lock, KeyRound, ShieldCheck } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Lock, KeyRound, ShieldCheck, Eye, EyeOff, Check } from "lucide-react"; import { createClient } from "@/lib/supabase/client"; import { useAuth } from "@/contexts/AuthContext"; import { Card, Button, FormField } from "@/components/ui"; +import { PasswordStrength } from "@/components/auth/PasswordStrength"; +import { validarSenha, SENHA_MIN } from "@/lib/auth/password-policy"; +import { getDefaultRoute } from "@/lib/constants/user-roles"; + +/** + * Tela única de "definir senha". Atende os três caminhos que existem hoje: + * primeiro acesso (must_change_password), recuperação por e-mail (chega aqui + * pelo /auth/callback) e troca voluntária. + * + * É uma tela só de propósito. Duplicar isso em /definir-senha e /reset-password + * criaria três lugares pra mesma regra divergir — e o middleware já sabe trazer + * pra cá quem precisa trocar senha. + */ + +/** + * O Supabase devolve erro de auth em inglês. Repassar cru quebra a tela no + * meio de um fluxo todo em português — e o cliente que travou aqui já está + * frustrado. Traduz o que dá pra prever; o resto cai no fallback. + */ +const ERROS_TRADUZIDOS: { padrao: RegExp; texto: string }[] = [ + { + padrao: /different from the old password/i, + texto: "Essa já é a sua senha atual. Escolha uma diferente.", + }, + { + padrao: /auth session missing|session_not_found|jwt expired/i, + texto: "Sua sessão expirou. Peça um novo link em \"Esqueci minha senha\".", + }, + { + padrao: /for security purposes|rate limit|too many requests/i, + texto: "Muitas tentativas seguidas. Espere um minuto e tente de novo.", + }, + { + padrao: /weak.?password|password.*(at least|too short)/i, + texto: `A senha precisa de pelo menos ${SENHA_MIN} caracteres, com letras e números.`, + }, +]; + +function traduzirErro(mensagem: string): string { + return ERROS_TRADUZIDOS.find((e) => e.padrao.test(mensagem))?.texto + ?? "Não consegui salvar a senha. Tente de novo em instantes."; +} export default function AlterarSenhaPage() { const supabase = createClient(); @@ -12,23 +54,47 @@ export default function AlterarSenhaPage() { const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + const [mostrarSenha, setMostrarSenha] = useState(false); + const [senhaTocada, setSenhaTocada] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // Primeiro acesso vs. recuperação: muda só o texto, nunca a regra. + const primeiroAcesso = !!profile?.must_change_password; + + // Validação ao vivo, a cada tecla. A política conhece nome e e-mail pra + // barrar a senha que é o próprio nome do dono — o primeiro palpite de + // qualquer atacante. + const veredito = useMemo( + () => validarSenha(password, { email: user?.email ?? profile?.email, nome: profile?.nome }), + [password, user?.email, profile?.email, profile?.nome], + ); + + const confirmPreenchida = confirmPassword.length > 0; + const senhasConferem = confirmPreenchida && password === confirmPassword; + const podeSalvar = veredito.ok && senhasConferem && !loading; + + // Erros da política só aparecem depois que o campo perdeu o foco. Mostrar + // "use pelo menos 10 caracteres" já na primeira tecla é acusar o usuário de + // um erro que ele ainda está no meio de não cometer. + const erroSenha = senhaTocada && password.length > 0 && !veredito.ok + ? veredito.erros.join(" ") + : undefined; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - setLoading(true); - try { - if (password.length < 6) { - throw new Error("A senha deve ter pelo menos 6 caracteres"); - } + // O botão já bloqueia este caso; a checagem fica como rede de segurança + // (submit por Enter, extensão de navegador, estado fora de sincronia). + if (!veredito.ok || !senhasConferem) { + setSenhaTocada(true); + return; + } - if (password !== confirmPassword) { - throw new Error("As senhas não coincidem"); - } + setLoading(true); + try { const { error: updateError } = await supabase.auth.updateUser({ password }); if (updateError) throw updateError; @@ -41,36 +107,51 @@ export default function AlterarSenhaPage() { console.error("Erro ao atualizar flag:", profileError); } - const targetRoute = profile?.role === "sdr" ? "/leads" : - profile?.role === "cliente" ? "/projetos" : "/dashboard"; + // Fonte única role→rota (user-roles.ts). A tela não guarda a própria + // tabela de destinos: role nova = uma entrada lá, zero aqui. + const targetRoute = profile?.role ? getDefaultRoute(profile.role) : "/"; await new Promise(resolve => setTimeout(resolve, 1000)); window.location.href = targetRoute; } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : "Erro ao atualizar senha"; - setError(errorMessage); + const bruto = err instanceof Error ? err.message : ""; + console.error("[AlterarSenha] Falha ao salvar:", bruto); + setError(traduzirErro(bruto)); setLoading(false); } }; + const olho = ( + + ); + return ( -
+
-
- +
+
-

- Defina sua senha +

+ {primeiroAcesso ? "Defina sua senha" : "Crie uma nova senha"}

-

- Este é seu primeiro acesso. Por segurança, defina uma nova senha pessoal. +

+ {primeiroAcesso + ? "Bem-vindo. Escolha a senha que você vai usar para entrar." + : "Escolha uma senha nova para a sua conta."}

@@ -81,35 +162,55 @@ export default function AlterarSenhaPage() {
)} -
- setPassword(e.target.value)} - placeholder="••••••••" - required - leftIcon={} - /> - - setConfirmPassword(e.target.value)} - placeholder="••••••••" - required - leftIcon={} - /> + +
+ setPassword(e.target.value)} + onBlur={() => setSenhaTocada(true)} + placeholder="••••••••••" + required + autoComplete="new-password" + autoFocus + leftIcon={} + rightIcon={olho} + error={erroSenha} + hint={erroSenha ? undefined : `Pelo menos ${SENHA_MIN} caracteres, com letras e números.`} + /> + +
+ +
+ setConfirmPassword(e.target.value)} + placeholder="••••••••••" + required + autoComplete="new-password" + leftIcon={} + rightIcon={olho} + error={confirmPreenchida && !senhasConferem ? "As senhas não coincidem." : undefined} + /> + {senhasConferem && ( +

+ + As senhas conferem. +

+ )} +
diff --git a/src/app/api/cron/health-score-snapshot/route.ts b/src/app/api/cron/health-score-snapshot/route.ts index f83fb66d..80dc110b 100644 --- a/src/app/api/cron/health-score-snapshot/route.ts +++ b/src/app/api/cron/health-score-snapshot/route.ts @@ -11,16 +11,15 @@ import { NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; import { calculateProjectHealthScore } from "@/lib/health-score-calculator"; +import { cronAutorizado } from "@/lib/auth/cron-auth"; export const runtime = "nodejs"; export const maxDuration = 60; export async function GET(req: Request) { - // Proteção simples com CRON_SECRET para evitar chamadas externas não autorizadas - const authHeader = req.headers.get("Authorization"); - const cronSecret = process.env.CRON_SECRET; - - if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + // Guard fail-closed em @/lib/auth/cron-auth. A versão anterior era + // fail-open: sem CRON_SECRET configurada, a checagem não acontecia. + if (!cronAutorizado(req)) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/cron/monthly-update-reminder/route.ts b/src/app/api/cron/monthly-update-reminder/route.ts index a52a3a79..0603bdb6 100644 --- a/src/app/api/cron/monthly-update-reminder/route.ts +++ b/src/app/api/cron/monthly-update-reminder/route.ts @@ -32,6 +32,7 @@ import { createNotification, type NotificationInput, } from '@/lib/notifications-service'; +import { cronAutorizado } from '@/lib/auth/cron-auth'; export const runtime = 'nodejs'; export const maxDuration = 60; @@ -62,10 +63,9 @@ const PROJECT_BATCH_SIZE = 50; // ─────────────────────────────────────────────────────────────────────────── export async function GET(req: Request) { - // Auth do cron (mesmo padrão dos outros crons) - const cronSecret = process.env.CRON_SECRET; - const authHeader = req.headers.get('Authorization'); - if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + // Guard fail-closed em @/lib/auth/cron-auth. Esta rota DISPARA E-MAIL — + // era a mais perigosa das três enquanto o guard era fail-open. + if (!cronAutorizado(req)) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/src/app/api/cron/sync-leadpool/route.ts b/src/app/api/cron/sync-leadpool/route.ts index 47510e7f..df03f642 100644 --- a/src/app/api/cron/sync-leadpool/route.ts +++ b/src/app/api/cron/sync-leadpool/route.ts @@ -21,6 +21,7 @@ import { NextResponse } from "next/server"; import { createClient as createSupabaseAdminClient, type SupabaseClient } from "@supabase/supabase-js"; import { buscarCNPJ } from "@/lib/cnpj-service"; import { mapPorteReceita, enderecoReceita, contatoFieldsFromReceita, qsaSociosRows } from "@/lib/lead-enrichment"; +import { cronAutorizado } from "@/lib/auth/cron-auth"; export const runtime = "nodejs"; export const maxDuration = 60; @@ -42,11 +43,9 @@ const BATCH_SIZE = 20; const DELAY_MS = 1500; export async function GET(req: Request) { - // Proteção com CRON_SECRET — mesmo padrão dos outros crons do projeto - const authHeader = req.headers.get("Authorization"); - const cronSecret = process.env.CRON_SECRET; - - if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + // Guard fail-closed em @/lib/auth/cron-auth. A versão anterior era + // fail-open: sem CRON_SECRET configurada, a checagem não acontecia. + if (!cronAutorizado(req)) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/onboarding/welcome/route.ts b/src/app/api/onboarding/welcome/route.ts index 95cc90a3..570efaba 100644 --- a/src/app/api/onboarding/welcome/route.ts +++ b/src/app/api/onboarding/welcome/route.ts @@ -1,33 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; import { sendWelcomeEmail } from "@/lib/email-service"; import { createClient } from "@/lib/supabase/server"; -import { createClient as createSupabaseClient } from "@supabase/supabase-js"; - -// Função helper para criar o cliente Admin do Supabase -function getSupabaseAdmin() { - const url = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - - if (!url || !serviceKey) { - throw new Error("Variáveis de ambiente do Supabase não configuradas."); - } - - return createSupabaseClient(url, serviceKey, { - auth: { - autoRefreshToken: false, - persistSession: false - } - }); -} - -function generateRandomPassword(): string { - const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$"; - let password = ""; - for (let i = 0; i < 12; i++) { - password += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return password; -} +import { + getSupabaseAdmin, + garantirUsuarioCliente, + gerarLinkDeAcesso, +} from "@/lib/auth/acesso-service"; + +/** + * Envio de boas-vindas disparado pelo CONSULTOR (botão na tela do cliente). + * + * A mecânica de garantir usuário + gerar link de acesso mora em + * `@/lib/auth/acesso-service` porque o webhook de pagamento vai precisar dela + * também — e sem sessão nenhuma. Aqui fica só o que é específico deste caminho: + * exigir consultor logado, buscar o cliente e ligar o projeto ao usuário. + */ export async function POST(request: NextRequest) { try { @@ -59,44 +46,22 @@ export async function POST(request: NextRequest) { const clientEmail = client.email_principal; const supabaseAdmin = getSupabaseAdmin(); - // Verificar se usuário já existe através de um truque de invite - // (Tentamos convidar. Se já existir no Auth, o Supabase Admin retorna erro que já existe e podemos tratar) - // Alternativa mais segura: listar usuários e filtrar por email (para poucos usuários funciona). - // Aqui vamos simplificar tentando listar. - const { data: listData } = await supabaseAdmin.auth.admin.listUsers(); - const existingUser = listData?.users.find(u => u.email === clientEmail); - - let targetUserId = existingUser?.id; - let generatedPassword; - - if (!existingUser) { - // CRIAR NOVO USUÁRIO - generatedPassword = generateRandomPassword(); - const { data: newUserData, error: createError } = await supabaseAdmin.auth.admin.createUser({ - email: clientEmail, - password: generatedPassword, - email_confirm: true, - user_metadata: { - nome: client.nome || "Cliente", - role: "cliente" - } - }); - - if (createError) { - console.error("[API/Welcome] Erro ao criar usuário Auth:", createError); - return NextResponse.json({ error: createError.message }, { status: 500 }); - } - - targetUserId = newUserData.user.id; - - // Marcar para mudar a senha no database/profiles - if (targetUserId) { - await supabaseAdmin.from("profiles").update({ - must_change_password: true, - nome: client.nome || "Cliente", - role: "cliente" - }).eq("id", targetUserId); - } + let targetUserId: string; + let usuarioNovo: boolean; + try { + const garantido = await garantirUsuarioCliente( + supabaseAdmin, + clientEmail, + client.nome || "Cliente", + ); + targetUserId = garantido.userId; + usuarioNovo = garantido.criadoAgora; + } catch (err) { + console.error("[API/Welcome] Erro ao garantir usuário Auth:", err); + return NextResponse.json( + { error: err instanceof Error ? err.message : "Falha ao criar usuário" }, + { status: 500 }, + ); } // Dar acesso ao projeto via project_members @@ -121,7 +86,24 @@ export async function POST(request: NextRequest) { const consultantEmail = profile?.email || user.email || ""; const consultantName = profile?.nome || "Consultor ATR Growth"; - // Enviar o email de Welcome (com a senha gerada, se houver, ou sem senha se ele já existia) + // Link de uso único para o cliente criar a própria senha. Substitui a + // senha temporária que antes viajava em texto puro dentro do e-mail. + // `generateLink` NÃO dispara e-mail — ele só devolve a URL, e quem manda + // somos nós, pelo Resend, com o nosso template. + let accessLink: string | undefined; + if (usuarioNovo) { + try { + accessLink = await gerarLinkDeAcesso(supabaseAdmin, clientEmail); + } catch (err) { + console.error("[API/Welcome] Erro ao gerar link de acesso:", err); + return NextResponse.json( + { error: "Conta criada, mas não consegui gerar o link de acesso. Tente reenviar." }, + { status: 500 }, + ); + } + } + + // Sem link = usuário já existia; o e-mail então só convida a entrar. const result = await sendWelcomeEmail({ clientEmail: clientEmail, clientName: client.nome || "Cliente", @@ -129,7 +111,7 @@ export async function POST(request: NextRequest) { consultantName, projectName, projectId, - temporaryPassword: generatedPassword // undefined se já existia + accessLink, }); if (!result.success) { diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts new file mode 100644 index 00000000..4253c316 --- /dev/null +++ b/src/app/auth/callback/route.ts @@ -0,0 +1,95 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +/** + * Ponto de aterrissagem de TODO link de autenticação enviado por e-mail: + * definir senha do primeiro acesso, recuperação, convite, confirmação. + * + * Sem esta rota o link do e-mail não vira sessão — ele chega, o Supabase + * devolve um código, e não havia nada no app que trocasse esse código por + * sessão. O clique caía num lugar que não sabia o que fazer com ele. + * + * Dois formatos chegam aqui, e qual deles depende de configuração do projeto + * Supabase e do template de e-mail. Tratar os dois evita que uma troca de + * template derrube o fluxo de acesso sem ninguém entender por quê: + * + * ?code= → fluxo PKCE → exchangeCodeForSession + * ?token_hash=&type= → link com OTP → verifyOtp + * + * Depois de estabelecer a sessão, manda pro `next`. O padrão é `/alterar-senha` + * porque quase todo link de e-mail existe justamente pra definir uma senha — e + * quem chegou por link de recuperação PRECISA passar por lá antes de qualquer + * outra tela, senão volta ao problema que o fez pedir o link. + */ + +/** Onde o link deposita o usuário quando não pede outro destino. */ +const DESTINO_PADRAO = "/alterar-senha"; + +/** + * Só aceita caminho interno. Sem isso, `?next=https://site-falso` transformaria + * a rota num redirecionador aberto — e um link com o domínio legítimo da ATR + * que joga o cliente em outro site é exatamente o material de um phishing + * convincente. Mesma regra que o login já aplica no `redirectTo`. + */ +function destinoSeguro(bruto: string | null): string { + if (!bruto) return DESTINO_PADRAO; + if (!bruto.startsWith("/") || bruto.startsWith("//")) return DESTINO_PADRAO; + return bruto; +} + +/** Volta pro login com um motivo legível — a tela traduz o código. */ +function falha(request: NextRequest, motivo: "link_invalido" | "link_expirado") { + const url = new URL("/login", request.url); + url.searchParams.set("erro", motivo); + return NextResponse.redirect(url); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + + const code = searchParams.get("code"); + const tokenHash = searchParams.get("token_hash"); + const type = searchParams.get("type"); + const next = destinoSeguro(searchParams.get("next")); + + // O Supabase manda o erro na própria URL quando o link já nasceu inválido + // (expirado no clique, já usado). Melhor tratar aqui do que tentar trocar + // um código que já sabemos que não presta. + const erroUrl = searchParams.get("error"); + const erroCodigo = searchParams.get("error_code"); + if (erroUrl) { + const expirou = erroUrl === "access_denied" || erroCodigo === "otp_expired"; + return falha(request, expirou ? "link_expirado" : "link_invalido"); + } + + const supabase = await createClient(); + + if (code) { + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (error) { + console.error("[AuthCallback] Falha ao trocar código por sessão:", error.message); + return falha(request, "link_expirado"); + } + return NextResponse.redirect(new URL(next, request.url)); + } + + if (tokenHash && type) { + const { error } = await supabase.auth.verifyOtp({ + token_hash: tokenHash, + // O `type` vem do template de e-mail em supabase/templates/*.html. + // `email_change` é o da troca de endereço (ConfigTab) — sem ele no + // union, a confirmação de troca de e-mail morre aqui em silêncio. + // Cast necessário porque a query string é sempre string solta. + type: type as "recovery" | "invite" | "signup" | "email" | "email_change" | "magiclink", + }); + if (error) { + console.error("[AuthCallback] Falha ao verificar token:", error.message); + return falha(request, "link_expirado"); + } + return NextResponse.redirect(new URL(next, request.url)); + } + + // Chegou aqui sem código nem token: link truncado pelo cliente de e-mail, + // copiado pela metade, ou alguém batendo na rota na mão. + return falha(request, "link_invalido"); +} diff --git a/src/app/esqueci-senha/page.tsx b/src/app/esqueci-senha/page.tsx new file mode 100644 index 00000000..a8398335 --- /dev/null +++ b/src/app/esqueci-senha/page.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { Mail, ArrowLeft, MailCheck } from "lucide-react"; +import { createClient } from "@/lib/supabase/client"; +import { Card, Button, FormField } from "@/components/ui"; + +/** + * Recuperação de senha — a porta que não existia. + * + * Enquanto o acesso nascia de convite com consultor do lado, quem esquecia a + * senha ligava pra alguém. Num produto vendido sozinho isso não existe: o + * cliente esquece a senha no segundo mês, não tem pra quem ligar, e some. + * + * Regra de ouro desta tela: **a resposta é sempre a mesma**, tenha o e-mail + * cadastro ou não. Dizer "e-mail não encontrado" entrega de graça quem é + * cliente da ATR — dá pra varrer uma lista inteira e descobrir quem usa o + * sistema. O custo de não contar é quase zero (quem digitou errado tenta de + * novo); o de contar não dá pra desfazer. + */ +export default function EsqueciSenhaPage() { + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [enviado, setEnviado] = useState(false); + + const supabase = createClient(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + // O link volta pelo /auth/callback, que troca o código por sessão e + // deposita o usuário na tela de definir senha. + const redirectTo = `${window.location.origin}/auth/callback?next=/alterar-senha`; + + const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), { redirectTo }); + + // Erro só vai pro console de propósito: a tela mostra sucesso de + // qualquer jeito (ver comentário do topo). O log é pra nós. + if (error) { + console.error("[EsqueciSenha] Falha ao enviar recuperação:", error.message); + } + + setLoading(false); + setEnviado(true); + }; + + return ( +
+
+
+ ATR Growth Business +
+ + + {enviado ? ( +
+
+ +
+

+ Confira seu e-mail +

+

+ Se {email} tiver + uma conta aqui, o link para criar uma nova senha já está a caminho. +

+

+ O link vale por 1 hora. Não esqueça de olhar o spam. +

+ + + Voltar para o login + +
+ ) : ( + <> +
+

+ Esqueceu sua senha? +

+

+ Digite o e-mail da sua conta e enviamos um link para você criar + uma nova. +

+
+ +
+ setEmail(e.target.value)} + placeholder="seu@email.com" + required + disabled={loading} + autoComplete="email" + autoFocus + leftIcon={} + /> + + + + +
+ + + Voltar para o login + +
+ + )} +
+ +

+ Assinou e ainda não recebeu seu acesso?{" "} + + Fale com a gente + +

+
+
+ ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 5539bf21..99235e03 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -3,6 +3,7 @@ import { useState, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Image from "next/image"; +import Link from "next/link"; import { Eye, EyeOff, LogIn, Loader2, Shield, Zap, TrendingUp } from "lucide-react"; import { createClient } from "@/lib/supabase/client"; import { Card, Button, FormField } from "@/components/ui"; @@ -19,6 +20,16 @@ export default function LoginPage() { ); } +/** + * Motivos que o /auth/callback devolve quando o link de e-mail não vira sessão. + * Traduzidos aqui porque a mensagem precisa terminar em ação — "link expirado" + * sozinho deixa o cliente sem saber o que fazer em seguida. + */ +const ERROS_DE_LINK: Record = { + link_expirado: "Esse link expirou ou já foi usado. Peça um novo em \"Esqueci minha senha\".", + link_invalido: "Não consegui ler esse link. Ele pode ter sido cortado pelo seu e-mail — tente copiar o endereço inteiro ou peça um novo.", +}; + function LoginContent() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -30,6 +41,8 @@ function LoginContent() { const router = useRouter(); const searchParams = useSearchParams(); + const erroDeLink = ERROS_DE_LINK[searchParams.get("erro") ?? ""] ?? null; + const supabase = createClient(); const handleSubmit = async (e: React.FormEvent) => { @@ -93,30 +106,34 @@ function LoginContent() {

- Sistema de Gestão + O financeiro da sua empresa,
- Empresarial + como ele é de verdade

- Potencialize seus resultados com análises financeiras inteligentes e gestão completa de projetos. + Do extrato bancário ao resultado do mês, sem planilha e sem esperar o + balancete do contador.

+ {/* Os três cartões falam do que o produto entrega HOJE. Até 08/2026 + prometiam "Gestão de Projetos", módulo desligado por flag — a tela + de entrada não pode vender o que o cliente não vai encontrar. */}
@@ -150,9 +167,9 @@ function LoginContent() {

- {error && ( + {(error || erroDeLink) && (
-

{error}

+

{error ?? erroDeLink}

)} @@ -206,9 +223,27 @@ function LoginContent() { -
-

- Acesso restrito a usuários autorizados +

+ + Esqueci minha senha + +
+ + {/* Até 08/2026 aqui dizia "Acesso restrito a usuários autorizados" — + frase de sistema interno. Quem assinou e está entrando pela + primeira vez precisa de uma saída, não de um aviso de portaria. */} +
+

+ Assinou e não recebeu seu acesso?{" "} + + Fale com a gente +

diff --git a/src/components/auth/PasswordStrength.tsx b/src/components/auth/PasswordStrength.tsx new file mode 100644 index 00000000..4d8e47b9 --- /dev/null +++ b/src/components/auth/PasswordStrength.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { forcaSenha } from "@/lib/auth/password-policy"; + +/** + * Barra de força da senha. Indicador, não porteiro — quem decide se a senha + * entra é `validarSenha` no submit. Aqui só mostramos onde ela está. + * + * Quatro segmentos em vez de uma barra contínua de propósito: segmento acende + * em degrau, e degrau dá a sensação de progresso que faz a pessoa digitar mais + * um pouco. Barra contínua vira decoração que ninguém lê. + */ +export function PasswordStrength({ senha }: { senha: string }) { + if (!senha) return null; + + const { score, rotulo, dica } = forcaSenha(senha); + + // Vermelho/âmbar/verde seguem os tokens de status do design system — a cor + // aqui é semântica (ruim/atenção/bom), não decorativa. + const cor = + score <= 1 ? "var(--status-error)" : + score === 2 ? "var(--alert-amber)" : + "var(--success-green)"; + + return ( +
+ + ); +} diff --git a/src/lib/auth/acesso-service.ts b/src/lib/auth/acesso-service.ts new file mode 100644 index 00000000..43983fa6 --- /dev/null +++ b/src/lib/auth/acesso-service.ts @@ -0,0 +1,144 @@ +import "server-only"; + +import { createClient as createSupabaseClient, type SupabaseClient } from "@supabase/supabase-js"; + +/** + * Entrega de acesso — o pedaço que transforma "pagou" em "consegue entrar". + * + * Vive separado das rotas porque tem DOIS chamadores com naturezas diferentes: + * + * 1. o consultor apertando "enviar boas-vindas" (`/api/onboarding/welcome`), + * que roda com sessão de quem clicou; + * 2. o webhook do gateway de pagamento, que roda SEM sessão nenhuma e não + * pode depender de ninguém estar com o navegador aberto. + * + * Os dois precisam da mesma coisa — garantir o usuário e gerar um link de uso + * único — e é aqui que essa regra mora, uma vez só. + * + * `server-only` no topo é proposital: este módulo usa a SERVICE_ROLE_KEY, que + * ignora RLS. Se alguém importar isso de um componente client por engano, o + * build quebra na hora em vez de vazar a chave pro navegador. + */ + +/** Cliente admin (service-role). Ignora RLS — use só em caminho de servidor. */ +export function getSupabaseAdmin(): SupabaseClient { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!url || !serviceKey) { + throw new Error("[AcessoService] Variáveis do Supabase não configuradas."); + } + + return createSupabaseClient(url, serviceKey, { + auth: { autoRefreshToken: false, persistSession: false }, + }); +} + +/** Base pública da app — de onde o link de acesso é montado. */ +export function getAppUrl(): string { + return process.env.NEXT_PUBLIC_SITE_URL + || process.env.NEXT_PUBLIC_APP_URL + || "https://app.atrgrowthbusiness.com.br"; +} + +/** + * Senha inicial da conta recém-criada. NUNCA é enviada a ninguém — o cliente + * define a dele pelo link. Existe só porque `createUser` exige uma. + */ +function senhaDescartavel(): string { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$"; + let senha = ""; + for (let i = 0; i < 24; i++) { + senha += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return senha; +} + +/** + * Procura o usuário pelo e-mail em `profiles` — tabela nossa e indexada + * (`idx_profiles_email`). + * + * ⚠️ NÃO usar `auth.admin.listUsers()` pra isso. Ele devolve só a primeira + * página (50 por padrão): passando de 50 usuários, a busca responde "não + * existe" pra todo mundo fora da primeira página, o `createUser` recusa por + * duplicidade e o cliente que pagou fica sem acesso — falha que só começa a + * acontecer quando as vendas funcionam. Mesma família do teto de 1000 linhas + * do PostgREST: a API responde com sucesso e devolve menos do que existe. + */ +export async function acharUsuarioPorEmail( + admin: SupabaseClient, + email: string, +): Promise { + const { data } = await admin + .from("profiles") + .select("id") + .eq("email", email) + .maybeSingle(); + + return (data as { id?: string } | null)?.id ?? null; +} + +export interface UsuarioGarantido { + userId: string; + /** true quando a conta nasceu agora (define quem recebe link de acesso). */ + criadoAgora: boolean; +} + +/** + * Garante que existe uma conta de autenticação para o e-mail, criando com role + * `cliente` se ainda não houver. Idempotente de propósito: webhook de gateway + * reentrega o mesmo evento e a segunda passada não pode gerar conta duplicada. + */ +export async function garantirUsuarioCliente( + admin: SupabaseClient, + email: string, + nome: string, +): Promise { + const existente = await acharUsuarioPorEmail(admin, email); + if (existente) return { userId: existente, criadoAgora: false }; + + const { data, error } = await admin.auth.admin.createUser({ + email, + password: senhaDescartavel(), + email_confirm: true, + user_metadata: { nome, role: "cliente" }, + }); + + if (error || !data.user) { + throw new Error(`Falha ao criar usuário: ${error?.message ?? "desconhecido"}`); + } + + await admin + .from("profiles") + .update({ must_change_password: true, nome, role: "cliente" }) + .eq("id", data.user.id); + + return { userId: data.user.id, criadoAgora: true }; +} + +/** + * Link de uso único para o cliente criar a própria senha. + * + * `generateLink` NÃO dispara e-mail — devolve só a URL. Quem manda somos nós, + * pelo Resend, com nosso template. É isso que permite o e-mail de compra ter + * recibo, próximos passos e a nossa identidade, em vez do template genérico + * de recuperação de senha do Supabase. + */ +export async function gerarLinkDeAcesso( + admin: SupabaseClient, + email: string, + destino = "/alterar-senha", +): Promise { + const { data, error } = await admin.auth.admin.generateLink({ + type: "recovery", + email, + options: { redirectTo: `${getAppUrl()}/auth/callback?next=${destino}` }, + }); + + const link = data?.properties?.action_link; + if (error || !link) { + throw new Error(`Falha ao gerar link de acesso: ${error?.message ?? "sem action_link"}`); + } + + return link; +} diff --git a/src/lib/auth/cron-auth.test.ts b/src/lib/auth/cron-auth.test.ts new file mode 100644 index 00000000..6a5d583c --- /dev/null +++ b/src/lib/auth/cron-auth.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { cronAutorizado } from "@/lib/auth/cron-auth"; + +const SEGREDO_ORIGINAL = process.env.CRON_SECRET; + +function requisicao(authorization?: string): Request { + return new Request("https://exemplo.test/api/cron/qualquer", { + headers: authorization ? { Authorization: authorization } : {}, + }); +} + +afterEach(() => { + if (SEGREDO_ORIGINAL === undefined) delete process.env.CRON_SECRET; + else process.env.CRON_SECRET = SEGREDO_ORIGINAL; +}); + +describe("cronAutorizado", () => { + it("RECUSA quando CRON_SECRET não está configurada", () => { + // O bug que este módulo conserta: a versão antiga liberava geral aqui, + // porque `if (cronSecret && ...)` vira falso com segredo undefined. + delete process.env.CRON_SECRET; + expect(cronAutorizado(requisicao("Bearer qualquer-coisa"))).toBe(false); + expect(cronAutorizado(requisicao())).toBe(false); + }); + + it("aceita o header exato que a Vercel manda", () => { + process.env.CRON_SECRET = "s3gr3d0-de-teste"; + expect(cronAutorizado(requisicao("Bearer s3gr3d0-de-teste"))).toBe(true); + }); + + it("recusa header ausente, vazio ou sem o prefixo Bearer", () => { + process.env.CRON_SECRET = "s3gr3d0-de-teste"; + expect(cronAutorizado(requisicao())).toBe(false); + expect(cronAutorizado(requisicao(""))).toBe(false); + expect(cronAutorizado(requisicao("s3gr3d0-de-teste"))).toBe(false); + }); + + it("recusa segredo errado, inclusive prefixo correto", () => { + process.env.CRON_SECRET = "s3gr3d0-de-teste"; + expect(cronAutorizado(requisicao("Bearer s3gr3d0-de-test"))).toBe(false); + expect(cronAutorizado(requisicao("Bearer s3gr3d0-de-testeX"))).toBe(false); + expect(cronAutorizado(requisicao("Bearer outro"))).toBe(false); + }); + + it("não estoura com tamanhos diferentes (timingSafeEqual exige igualdade)", () => { + process.env.CRON_SECRET = "curto"; + expect(() => cronAutorizado(requisicao("Bearer " + "x".repeat(500)))).not.toThrow(); + expect(cronAutorizado(requisicao("Bearer " + "x".repeat(500)))).toBe(false); + }); +}); diff --git a/src/lib/auth/cron-auth.ts b/src/lib/auth/cron-auth.ts new file mode 100644 index 00000000..2340317d --- /dev/null +++ b/src/lib/auth/cron-auth.ts @@ -0,0 +1,61 @@ +import { timingSafeEqual } from "node:crypto"; + +/** + * Autorização dos endpoints de cron. + * + * ── Por que existe ────────────────────────────────────────────────────────── + * Os três crons (`/api/cron/*`) repetiam este guard, e todos com o MESMO + * defeito: + * + * if (cronSecret && authHeader !== `Bearer ${cronSecret}`) return 401 + * + * Isso é **fail-open**: quando `CRON_SECRET` não está configurada, `cronSecret` + * é `undefined`, a condição inteira vira falsa e a checagem simplesmente não + * acontece. Segredo ausente passou a significar "deixa todo mundo entrar". + * + * E era exatamente esse o estado de produção em 12/08/2026: `CRON_SECRET` não + * existia nem no `.env.local` nem na Vercel. Os três endpoints estavam abertos + * — sendo que `monthly-update-reminder` **dispara e-mail**, então qualquer um + * que descobrisse a URL rodava uma remessa nos clientes e queimava a cota do + * Resend. O middleware não cobre `/api` (o matcher exclui), então não havia + * segunda barreira. + * + * Aqui é **fail-closed**: sem segredo configurado, ninguém entra. Um cron que + * para de rodar é um incidente que aparece; um endpoint aberto é um incidente + * que não aparece. + * + * ── Como a Vercel passa ───────────────────────────────────────────────────── + * Com `CRON_SECRET` definida no projeto, a Vercel envia + * `Authorization: Bearer ` nas invocações agendadas do + * `vercel.json`. Ou seja: configurar a variável é o que faz o cron legítimo + * continuar passando e o resto do mundo parar de passar. + */ + +/** + * Comparação em tempo constante. O ganho real deste módulo é o fail-closed + * acima, não isto — mas comparar segredo com `===` vaza, por tempo de resposta, + * quantos caracteres iniciais bateram, e o custo de fazer certo é nulo. + */ +function iguaisEmTempoConstante(a: string, b: string): boolean { + const bufA = Buffer.from(a, "utf8"); + const bufB = Buffer.from(b, "utf8"); + // `timingSafeEqual` estoura se os tamanhos diferem; comparar tamanho antes + // vaza só o comprimento, que não é segredo útil. + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); +} + +/** + * `true` só quando `CRON_SECRET` está configurada E o header bate exatamente. + * Qualquer outra combinação — segredo ausente, header ausente, header errado — + * devolve `false`. + */ +export function cronAutorizado(req: Request): boolean { + const segredo = process.env.CRON_SECRET; + if (!segredo) return false; + + const header = req.headers.get("Authorization"); + if (!header) return false; + + return iguaisEmTempoConstante(header, `Bearer ${segredo}`); +} diff --git a/src/lib/auth/password-policy.test.ts b/src/lib/auth/password-policy.test.ts new file mode 100644 index 00000000..b105f8b4 --- /dev/null +++ b/src/lib/auth/password-policy.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { validarSenha, forcaSenha, SENHA_MIN } from "@/lib/auth/password-policy"; + +describe("validarSenha", () => { + it("aceita uma senha longa com letra e número", () => { + expect(validarSenha("cavaloAzul42").ok).toBe(true); + }); + + it("recusa abaixo do mínimo", () => { + const r = validarSenha("abc123"); + expect(r.ok).toBe(false); + expect(r.erros.join(" ")).toContain(String(SENHA_MIN)); + }); + + it("recusa só letras e só números", () => { + expect(validarSenha("cavalobrancoazul").ok).toBe(false); + expect(validarSenha("98765432198").ok).toBe(false); + }); + + it("recusa as senhas óbvias da lista", () => { + expect(validarSenha("password123").ok).toBe(false); + expect(validarSenha("1234567890").ok).toBe(false); + // Acento e caixa não devem servir de disfarce. + expect(validarSenha("SENHA1234").ok).toBe(false); + }); + + it("recusa sequência e repetição de 4+", () => { + expect(validarSenha("meucofre1234").ok).toBe(false); + expect(validarSenha("meucofreaaaa1").ok).toBe(false); + expect(validarSenha("cofre4321seguro").ok).toBe(false); + }); + + it("tolera sequência de 3, que aparece em senha legítima", () => { + expect(validarSenha("meucofre123x").ok).toBe(true); + }); + + it("recusa senha que contém o e-mail ou o nome", () => { + const ctx = { email: "marcelo@vertimetal.com.br", nome: "Marcelo Antunes" }; + expect(validarSenha("marcelo9876x", ctx).ok).toBe(false); + expect(validarSenha("xxantunes77y", ctx).ok).toBe(false); + // Sem acento na senha, com acento no nome — ainda pega. + expect(validarSenha("antonio55zzz", { nome: "Antônio Silva" }).ok).toBe(false); + }); + + it("ignora pedaços curtos do nome (não barra senha boa por acaso)", () => { + expect(validarSenha("cavaloAzul42", { nome: "Ana da Silva" }).ok).toBe(true); + }); + + it("junta todos os problemas numa validação só", () => { + const r = validarSenha("ana", { nome: "Ana" }); + expect(r.ok).toBe(false); + expect(r.erros.length).toBeGreaterThan(1); + }); +}); + +describe("forcaSenha", () => { + it("nunca passa de 'fraca' quando a senha é inválida", () => { + // Mostrar "boa" pra algo que o botão vai recusar é mentir pro usuário. + expect(forcaSenha("password123").score).toBeLessThanOrEqual(1); + expect(forcaSenha("abc123").score).toBeLessThanOrEqual(1); + }); + + it("senha vazia é muito fraca e não dá dica", () => { + expect(forcaSenha("")).toEqual({ score: 0, rotulo: "muito fraca", dica: null }); + }); + + it("sobe conforme comprimento e variedade", () => { + const curta = forcaSenha("cavaloAzul42"); + const longa = forcaSenha("cavaloAzulCorre42!"); + expect(longa.score).toBeGreaterThan(curta.score); + }); + + it("frase longa sem símbolo chega em forte", () => { + expect(forcaSenha("meuCavaloAzulCorreNoCampo7").rotulo).toBe("forte"); + }); + + it("para de dar dica quando já está forte", () => { + expect(forcaSenha("meuCavaloAzulCorreNoCampo7!").dica).toBeNull(); + }); + + it("toda senha válida marca pelo menos razoável", () => { + const r = forcaSenha("cavaloazul42"); + expect(validarSenha("cavaloazul42").ok).toBe(true); + expect(r.score).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/src/lib/auth/password-policy.ts b/src/lib/auth/password-policy.ts new file mode 100644 index 00000000..98b20b76 --- /dev/null +++ b/src/lib/auth/password-policy.ts @@ -0,0 +1,197 @@ +/** + * Política de senha — fonte única. + * + * Vale para TODA tela que define senha (primeiro acesso, recuperação, troca + * voluntária). Módulo puro de propósito: sem Supabase, sem React, sem I/O — dá + * pra testar a regra sem subir nada e pra rodar igual no server e no browser. + * + * Nível de exigência escolhido: um degrau acima do mínimo do Supabase (6 chars, + * sem nenhuma outra regra), sem cair em teatro de segurança. O produto guarda + * extrato bancário e resultado de empresa; 6 caracteres livres é fraco demais + * pra isso. Mas exigir símbolo obrigatório + troca a cada 90 dias empurra o dono + * pro post-it no monitor, que é pior que a senha fraca. O que fica: + * + * 1. mínimo de 10 caracteres — comprimento é o que mais pesa contra força bruta + * 2. pelo menos uma letra e um número — barra "aaaaaaaaaa" e "1234567890" + * 3. nada de senha óbvia (lista curta das campeãs, incluindo as brasileiras) + * 4. nada de sequência/repetição longa ("abcd", "1111") + * 5. a senha não pode conter o próprio e-mail ou nome — o erro mais comum de + * todos, e o primeiro que um atacante tenta + * + * Símbolo NÃO é obrigatório: ele entra no cálculo de força (incentivo), não no + * de validade (barreira). Uma frase longa é melhor senha que "Ab1!" e a regra + * precisa deixar isso possível. + */ + +/** Comprimento mínimo aceito. Vale pra toda tela que define senha. */ +export const SENHA_MIN = 10; + +/** + * Campeãs de vazamento + as variantes brasileiras que aparecem em qualquer + * lista real. Não pretende ser exaustiva (isso é trabalho de biblioteca com + * dicionário de milhões de entradas); pretende barrar o que de fato é digitado + * quando alguém não quer pensar numa senha. + */ +const SENHAS_COMUNS = new Set([ + "senha", "senha123", "senha1234", "senhasenha", "minhasenha", + "password", "password1", "password123", "passw0rd", + "123456", "1234567", "12345678", "123456789", "1234567890", + "qwerty", "qwerty123", "qwertyuiop", "asdfghjkl", + "abc123", "abcd1234", "a1b2c3d4", + "brasil", "brasil123", "flamengo", "corinthians", "palmeiras", + "admin", "admin123", "administrador", "root", "teste", "teste123", + "atrgrowth", "atros", "atros123", "atrgrowth123", + "iloveyou", "welcome", "letmein", "monkey", "dragon", +]); + +export interface ContextoSenha { + /** E-mail do usuário — a parte antes do @ vira termo proibido. */ + email?: string | null; + /** Nome do usuário — cada pedaço com 4+ letras vira termo proibido. */ + nome?: string | null; +} + +export interface ResultadoValidacao { + ok: boolean; + /** Vazio quando ok. Todos os problemas de uma vez — não um por vez. */ + erros: string[]; +} + +/** Rótulos de força, do mais fraco ao mais forte. */ +export type ForcaRotulo = "muito fraca" | "fraca" | "razoável" | "boa" | "forte"; + +export interface ResultadoForca { + /** 0 a 4. Só é ≥2 quando a senha passa em validarSenha. */ + score: 0 | 1 | 2 | 3 | 4; + rotulo: ForcaRotulo; + /** Dica única do próximo passo pra melhorar. `null` quando já está forte. */ + dica: string | null; +} + +/** Normaliza pra comparação: minúscula e sem acento. */ +function normalizar(texto: string): string { + return texto + .toLowerCase() + .normalize("NFD") + .replace(/[̀-ͯ]/g, ""); +} + +/** + * Detecta 4+ caracteres em sequência no code point (`abcd`, `4321`) ou 4+ + * repetições do mesmo caractere (`aaaa`). Quatro é o limiar porque três + * aparece por acidente em senha legítima ("casa123" tem "123"). + */ +function temSequenciaOuRepeticao(senha: string): boolean { + const s = normalizar(senha); + if (s.length < 4) return false; + + let repetidos = 1; + let subindo = 1; + let descendo = 1; + + for (let i = 1; i < s.length; i++) { + const delta = s.charCodeAt(i) - s.charCodeAt(i - 1); + + repetidos = delta === 0 ? repetidos + 1 : 1; + subindo = delta === 1 ? subindo + 1 : 1; + descendo = delta === -1 ? descendo + 1 : 1; + + if (repetidos >= 4 || subindo >= 4 || descendo >= 4) return true; + } + + return false; +} + +/** + * Termos que não podem aparecer dentro da senha: o local-part do e-mail e cada + * pedaço do nome com 4+ letras. Pedaços curtos ficam de fora de propósito — + * proibir "de"/"da"/"ana" barraria senhas boas por acaso. + */ +function termosProibidos(ctx: ContextoSenha): string[] { + const termos: string[] = []; + + const local = ctx.email?.split("@")[0]; + if (local && local.length >= 4) termos.push(normalizar(local)); + + if (ctx.nome) { + for (const parte of normalizar(ctx.nome).split(/\s+/)) { + if (parte.length >= 4) termos.push(parte); + } + } + + return termos; +} + +/** + * Valida a senha contra a política. Devolve TODOS os problemas de uma vez — + * corrigir um erro e descobrir o próximo só no próximo clique é o padrão de + * formulário que faz gente desistir. + */ +export function validarSenha(senha: string, ctx: ContextoSenha = {}): ResultadoValidacao { + const erros: string[] = []; + + if (senha.length < SENHA_MIN) { + erros.push(`Use pelo menos ${SENHA_MIN} caracteres.`); + } + + if (!/\p{L}/u.test(senha) || !/\d/.test(senha)) { + erros.push("Misture letras e números."); + } + + const normalizada = normalizar(senha); + + if (SENHAS_COMUNS.has(normalizada)) { + erros.push("Essa senha é conhecida demais. Escolha outra."); + } + + if (temSequenciaOuRepeticao(senha)) { + erros.push("Evite sequências como \"1234\" ou letras repetidas."); + } + + if (termosProibidos(ctx).some((termo) => normalizada.includes(termo))) { + erros.push("A senha não pode conter seu nome nem seu e-mail."); + } + + return { ok: erros.length === 0, erros }; +} + +/** + * Mede a força pra mostrar na tela. É indicador, não porteiro: quem decide se + * a senha entra é `validarSenha`. Uma senha pode ser válida e ainda assim + * marcar "razoável" — a barra existe pra convidar a melhorar, não pra barrar. + */ +export function forcaSenha(senha: string): ResultadoForca { + if (!senha) return { score: 0, rotulo: "muito fraca", dica: null }; + + const { ok } = validarSenha(senha); + + // Senha que não passa na política nunca mostra mais que "fraca" — mostrar + // "boa" pra algo que o botão vai recusar é mentir pro usuário. + if (!ok) { + const score = senha.length >= 6 ? 1 : 0; + return { + score: score as 0 | 1, + rotulo: score === 1 ? "fraca" : "muito fraca", + dica: senha.length < SENHA_MIN ? `Faltam ${SENHA_MIN - senha.length} caracteres.` : null, + }; + } + + let pontos = 0; + if (senha.length >= SENHA_MIN) pontos++; + if (senha.length >= 14) pontos++; + if (senha.length >= 18) pontos++; + if (/[a-z]/.test(senha) && /[A-Z]/.test(senha)) pontos++; + if (/[^\p{L}\d]/u.test(senha)) pontos++; + + const score = Math.min(4, Math.max(2, pontos)) as 2 | 3 | 4; + const rotulo: ForcaRotulo = score === 4 ? "forte" : score === 3 ? "boa" : "razoável"; + + let dica: string | null = null; + if (score < 4) { + if (senha.length < 14) dica = "Senhas mais longas são bem mais difíceis de quebrar."; + else if (!/[^\p{L}\d]/u.test(senha)) dica = "Um símbolo deixaria ela ainda mais forte."; + else dica = "Misturar maiúsculas e minúsculas ajuda."; + } + + return { score, rotulo, dica }; +} diff --git a/src/lib/email-service.ts b/src/lib/email-service.ts index 09d6aa44..3ff8f64b 100644 --- a/src/lib/email-service.ts +++ b/src/lib/email-service.ts @@ -19,8 +19,17 @@ function getResendClient(): Resend { return new Resend(apiKey); } +/** + * Remetente dos e-mails automáticos. O fallback é o mesmo endereço configurado + * no SMTP do Supabase (supabase/config.toml) e no link de ajuda das telas de + * login e recuperação — os quatro pontos precisam apontar pro mesmo lugar, + * senão o cliente responde pra um endereço que ninguém lê. + * + * `acesso@` é alias encaminhando pra caixa real. O default aqui existe só como + * rede de segurança: em produção quem manda é `RESEND_FROM_EMAIL`. + */ function getFromEmail(): string { - return process.env.RESEND_FROM_EMAIL || "ATR Growth "; + return process.env.RESEND_FROM_EMAIL || "ATR OS "; } // ============================================================ @@ -44,6 +53,24 @@ export interface WelcomeEmailParams { consultantName: string; projectName: string; projectId: string; + /** + * Link de uso único para o cliente criar a própria senha (gerado por + * `admin.generateLink`). **Forma preferida** — o link expira e só serve uma + * vez, ao contrário da senha abaixo, que fica na caixa de entrada para + * sempre. Ausente quando o usuário já tinha conta. + */ + accessLink?: string; + /** + * ⚠️ LEGADO — senha em texto puro no corpo do e-mail. + * + * Continua aqui porque `client-users-service` (modal de convite do admin) + * TAMBÉM mostra essa senha na tela, com botão de copiar, e migrar esse + * fluxo pro link muda o jeito que o time trabalha hoje — é decisão de + * produto, não de código. Enquanto isso, `accessLink` tem precedência: se + * os dois vierem, a senha é ignorada. + * + * Ao migrar aquele fluxo, apagar este campo. + */ temporaryPassword?: string; } @@ -54,7 +81,11 @@ export async function sendWelcomeEmail(params: WelcomeEmailParams): Promise senha legada > "você já tem acesso". + const blocoSenhaLegado = params.temporaryPassword ? `

🔐 Suas Credenciais de Acesso

-

- Foi criado um acesso exclusivo para você acompanhar o projeto na nossa plataforma ATR OS. -

- +
Login: ${params.clientEmail} @@ -100,21 +129,48 @@ function buildWelcomeHtml(params: WelcomeEmailParams): string {

- * No seu primeiro login, o sistema solicitará automaticamente que você crie uma nova senha de sua preferência. + * No seu primeiro login, o sistema vai pedir que você crie uma senha sua.

- ` : ` + ` : null; + + const credenciaisHtml = params.accessLink ? ` +
+

+ 🔐 Seu Acesso +

+

+ Seu acesso ao ATR OS está pronto. Falta só criar sua senha — leva 30 segundos. +

+ +

+ Se o botão não funcionar, copie e cole este endereço no navegador: +

+

+ ${params.accessLink} +

+

+ Seu login é ${params.clientEmail}. + O link vale por 1 hora e só pode ser usado uma vez — se expirar, é só pedir outro em + "Esqueci minha senha" na tela de login. +

+
+ ` : blocoSenhaLegado ?? `

Você já possui um acesso em nossa plataforma. Faça login para acompanhar o andamento do seu novo projeto.

@@ -222,3 +278,220 @@ function buildWelcomeHtml(params: WelcomeEmailParams): string { `.trim(); } +// ============================================================ +// EMAIL DE ACESSO PÓS-COMPRA (SELF-SERVICE) +// ============================================================ + +/** + * O e-mail mais importante do produto: é a primeira coisa que alguém que acabou + * de pagar R$ 897,90 vê. Ele tem três trabalhos, nesta ordem: + * + * 1. confirmar que deu certo (alívio — dinheiro saiu e nada apareceu ainda) + * 2. entregar o acesso, num clique só + * 3. dizer o que vem depois, pra ninguém achar que vai cair num painel pronto + * + * O terceiro é o que mais protege contra cancelamento no mês 1. Um cliente que + * entra esperando dashboard preenchido e encontra tela vazia se sente enganado; + * o que já sabe que precisa subir extratos entra pra trabalhar. + * + * Difere do `sendWelcomeEmail` de propósito: aquele é da era da consultoria + * (fala em consultor responsável, projeto, diagnóstico de 7 dias). Aqui não há + * consultor nenhum — o cliente comprou sozinho e vai operar sozinho. + */ +export interface AcessoCompraEmailParams { + email: string; + /** Nome de quem comprou. Só o primeiro nome é usado na saudação. */ + nome: string; + /** Razão social/nome fantasia — dá pertencimento e confirma o CNPJ certo. */ + empresa: string; + /** Link de uso único (gerado por `gerarLinkDeAcesso`). */ + accessLink: string; + /** "Mensal", "Semestral", "Anual". */ + plano: string; + /** Já formatado: "R$ 9.158,58". */ + valorFormatado: string; + /** Como foi cobrado: "à vista, no Pix" / "por mês, no cartão" / "em 12x". */ + formaPagamento: string; + /** Data legível da próxima cobrança/renovação. Ausente = não recorre. */ + proximaCobranca?: string; + /** Chave de idempotência — o webhook reentrega o mesmo evento. */ + idempotencyKey?: string; +} + +export async function sendAcessoCompraEmail(params: AcessoCompraEmailParams): Promise { + const resend = getResendClient(); + + const { data, error } = await resend.emails.send({ + from: getFromEmail(), + to: [params.email], + subject: "Seu acesso ao ATR OS está pronto", + html: buildAcessoCompraHtml(params), + // Sem CC. O link loga na conta do cliente — mandar cópia pra qualquer + // terceiro é entregar a chave. + // @ts-expect-error - idempotencyKey is a valid Resend param + idempotencyKey: params.idempotencyKey, + tags: [{ name: "category", value: "acesso-compra" }], + }); + + if (error) { + console.error("[EmailService] Erro Resend (AcessoCompra):", error); + return { success: false, error: error.message }; + } + + console.log(`[EmailService] Acesso pós-compra enviado para ${params.email} (ID: ${data?.id})`); + return { success: true, emailId: data?.id }; +} + +function buildAcessoCompraHtml(params: AcessoCompraEmailParams): string { + const primeiroNome = params.nome.trim().split(/\s+/)[0] || "tudo bem"; + + const linhaRenovacao = params.proximaCobranca + ? `Próxima cobrança + ${params.proximaCobranca}` + : ""; + + return ` + + + + + + Seu acesso ao ATR OS está pronto + + + +
Falta só criar sua senha — leva 30 segundos.
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

ATR OS

+

+ Gestão Financeira Empresarial +

+
+

+ Bem-vindo, ${primeiroNome}. +

+

+ Sua assinatura do ATR OS está ativa, no CNPJ da + ${params.empresa}. +

+

+ Para entrar, crie sua senha: +

+
+ + Criar minha senha + +
+

+ Se o botão não funcionar, copie e cole este endereço no navegador: +

+

+ ${params.accessLink} +

+

+ Seu login é ${params.email}. O link vale por + 24 horas e só pode ser usado uma vez — se expirar, + é só pedir outro em "Esqueci minha senha" na tela de login. +

+
+
+

+ O que vem depois +

+ + + + + + + + + + +
+ 1. + Confira sua empresa — já preenchemos o que dava pelo seu CNPJ. +
+ 2. + Cadastre suas contas bancárias — é por elas que os extratos entram. +
+ 3. + Suba seus extratos — é deles que sai todo o resto: resultado, caixa, dívida. +
+

+ Os dois primeiros levam uns 5 minutos. O sistema conduz você passo a passo. +

+
+
+ + + + + + + + + + + + + ${linhaRenovacao} + + + + +
+ Sua assinatura +
Plano${params.plano}
Valor + ${params.valorFormatado} ${params.formaPagamento} +
Nota fiscalem até 5 dias úteis
+
+

+ Travou em algum passo? Responda este e-mail — a gente lê. +

+

+ ATR OS — Gestão Financeira Empresarial +

+
+
+ +`.trim(); +} + diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index 70247403..0bfa24d0 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -79,15 +79,26 @@ export async function updateSession(request: NextRequest) { // ============================================ // ROTAS PÚBLICAS (default deny: o resto é protegido) // ============================================ + // ⚠️ Só entra aqui rota que EXISTE. Até 08/2026 esta lista liberava + // '/register', '/forgot-password' e '/reset-password' — três páginas que + // nunca foram construídas. Um allow-list apontando pra 404 não é inofensivo: + // dá a impressão de que a recuperação de senha existe e some da lista de + // pendências de quem lê o middleware procurando o que já está pronto. const publicRoutes = [ '/login', - '/register', - '/forgot-password', - '/reset-password', - '/convite', // Página de aceitar convite + '/esqueci-senha', // Pedir link de recuperação + '/auth', // /auth/callback: aterrissagem dos links de e-mail + '/convite', // Página de aceitar convite ] const isPublicRoute = publicRoutes.some(route => pathname.startsWith(route)) + // Rotas públicas que um usuário JÁ LOGADO precisa conseguir abrir (regra 3 + // abaixo chuta o logado de volta pra home). O convite é o caso clássico; o + // /auth/callback é o novo: quem clica no link de recuperação já estando + // logado no mesmo navegador seria devolvido pra home ANTES do código virar + // sessão — e a troca de senha silenciosamente não aconteceria. + const isPublicRouteParaLogado = pathname.startsWith('/convite') || pathname.startsWith('/auth') + if (process.env.NODE_ENV === 'development' && pathname !== '/') { console.log(`[Proxy] ${pathname} → ${user ? user.email : 'anônimo'} (${isPublicRoute ? 'Público' : 'Protegido'})`) } @@ -123,14 +134,14 @@ export async function updateSession(request: NextRequest) { } // 2. Precisa trocar senha → /alterar-senha ANTES de qualquer roteamento por - // role (exceto na própria tela e no fluxo de convite). - if (user && mustChangePassword && pathname !== '/alterar-senha' && !pathname.startsWith('/convite')) { + // role (exceto na própria tela, no convite e no callback de e-mail). + if (user && mustChangePassword && pathname !== '/alterar-senha' && !isPublicRouteParaLogado) { return redirect('/alterar-senha') } // 3. Autenticado numa rota de auth (login, etc.) → manda pra home da role. - // Convite segue acessível pra logado (aceitar convite). - if (user && isPublicRoute && !pathname.startsWith('/convite')) { + // Convite e callback seguem acessíveis pra logado (ver acima). + if (user && isPublicRoute && !isPublicRouteParaLogado) { const destino = await resolveLandingPath(supabase, user.id, role) // GUARD ANTI-LOOP: com a role irresolvível (sessão em auth.users sem linha // em `profiles`, ou RLS+RPC vazias), `resolveLandingPath` devolve '/login' diff --git a/supabase/README.md b/supabase/README.md new file mode 100644 index 00000000..1ab1de02 --- /dev/null +++ b/supabase/README.md @@ -0,0 +1,97 @@ +# Supabase — como este projeto está configurado + +## Onde mora a verdade de cada coisa + +| O quê | Onde é configurado | Este repositório | +|---|---|---| +| Schema do banco | `supabase/migrations/` | ✅ fonte da verdade | +| SMTP de e-mail | **Dashboard** (Authentication → Emails → SMTP Settings) | 📄 só documentado em `config.toml` | +| Templates de e-mail | **Dashboard** (Authentication → Emails → Templates) | 📄 HTML em `supabase/templates/`, colado à mão | +| Site URL / Redirect URLs | **Dashboard** (Authentication → URL Configuration) | — | +| Rate limit de e-mail | **Dashboard** (Authentication → Rate Limits) | 📄 `config.toml` tem o valor de local/preview | + +> ⚠️ **Mexeu num HTML de `supabase/templates/`? Tem que colar no dashboard.** +> O arquivo sozinho não muda o e-mail que o cliente recebe. O motivo está abaixo. + +## Por que a configuração de e-mail não mora no config.toml + +O `config.toml` **não** é documentação da produção — ele é configuração executável +do `supabase start` local e das **Preview Branches**. E no plano gratuito, qualquer +configuração de e-mail ali derruba o preview, pelos dois lados: + +``` +com [auth.email.smtp] ligado → 401 "Missing SMTP_PASS fields" + env(RESEND_API_KEY) não existe no ambiente de preview + +sem [auth.email.smtp] → 400 "Email template modification is not available + for free tier projects using the default email provider" +``` + +Um beco sem saída: com SMTP falta o segredo, sem SMTP o free tier proíbe template +customizado. Por isso os blocos estão comentados no `config.toml`, servindo de +registro do que está no dashboard. + +Produção funciona porque **lá** o SMTP customizado está ligado — e com SMTP +customizado o free tier libera template. A limitação só atinge preview. + +## Quando virar Supabase Pro + +Ordem de importância pro produto, não de facilidade: + +### 1. Backup e recuperação — é o motivo real de subir de plano + +O sistema guarda o histórico financeiro inteiro dos clientes: extratos, DRE +fechada, snapshots mensais imutáveis. Perder isso não é incidente, é fim de +relação. Antes do primeiro cliente pagante, backup diário e recuperação a +ponto-no-tempo deixam de ser luxo. + +Conferir no painel de billing o que o plano inclui e o que é add-on. + +### 2. Projeto que não pausa + +Projeto no plano gratuito **pausa por inatividade**. Um cliente que entra depois +de dez dias sem uso e encontra o sistema fora do ar é churn na hora — e é a +falha mais constrangedora possível num produto que ele paga. + +### 3. Retenção de log + +Quando acontecer "o cliente pagou e não recebeu acesso", o log de Auth é onde se +descobre por quê. No free tier a janela de retenção é curta demais para +investigar algo que o cliente reporta dias depois. + +### 4. Aí sim, os templates voltam pro config.toml + +Com plano pago, `[auth.email.template.*]` deixa de ser bloqueado. Descomentar em +`config.toml` e o preview passa a aplicar os HTMLs sozinho — some o passo manual +de colar no dashboard. + +**Passos, quando chegar a hora:** + +1. Descomentar `[auth.email.template.recovery]` e `[auth.email.template.email_change]` +2. Subir `email_sent` de `2` para `100` (produção já está em 100 pelo dashboard) +3. Rodar o CI e confirmar que a Supabase Preview segue verde + +O bloco `[auth.email.smtp]` é caso à parte: mesmo no Pro ele só funciona em +preview se `RESEND_API_KEY` existir naquele ambiente, o que exige cadastrar o +segredo na integração. **Antes de fazer isso, decidir se preview deve mandar +e-mail de verdade** — hoje não manda, e isso é bom: gastaria cota do Resend e +poderia disparar mensagem real a partir de um deploy de teste. + +## Templates existentes + +Só os dois fluxos que o produto realmente dispara: + +| Arquivo | Dispara de | Assunto no dashboard | +|---|---|---| +| `templates/recovery.html` | `/esqueci-senha` (`resetPasswordForEmail`) | Criar uma nova senha — ATR OS | +| `templates/email_change.html` | `ConfigTab.tsx:96` (`updateUser({ email })`) | Confirme a troca de e-mail — ATR OS | + +Os demais (invite, confirmation, magic link, reauthentication) **não estão +ligados** no produto: o convite tem tabela e tela próprias (`/convite`) e +`enable_confirmations` está `false`. Não criar template para fluxo que não existe. + +Os links apontam para o **nosso domínio** via `{{ .TokenHash }}`, em vez do +`{{ .ConfirmationURL }}` padrão do Supabase — assim o cliente vê um endereço que +reconhece, e quando o link falha quem mostra o erro é a nossa tela de login em +português. A rota `/auth/callback` aceita os dois formatos, então voltar ao +padrão é trocar uma linha. diff --git a/supabase/config.toml b/supabase/config.toml index 74c23f1c..c398efd6 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -143,6 +143,15 @@ password_requirements = "" [auth.rate_limit] # Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +# +# ⚠️ EM PRODUÇÃO ESTE VALOR É 100, configurado no dashboard (Authentication → +# Rate Limits). Aqui fica 2 de propósito: este arquivo governa o `supabase start` +# local e as PREVIEW BRANCHES, e nenhum dos dois tem SMTP customizado — o +# Supabase recusa `RATE_LIMIT_EMAIL_SENT` sem SMTP_PASS e derruba o preview +# inteiro com 401. Ver o bloco [auth.email.smtp] comentado abaixo. +# +# O 2 é o default do serviço embutido de teste. Em produção seria bloqueio, não +# proteção: 3 pessoas assinando na mesma hora e a terceira não recebe o acesso. email_sent = 2 # Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. sms_sent = 30 @@ -180,20 +189,68 @@ otp_length = 6 # Number of seconds before the email OTP expires (defaults to 1 hour). otp_expiry = 3600 -# Use a production-ready SMTP server +# SMTP de produção — Resend. ⚠️ COMENTADO DE PROPÓSITO: isto é registro do que +# está configurado, não configuração ativa. +# +# Por que não pode ficar ligado aqui: `env(RESEND_API_KEY)` não resolve no +# ambiente da Supabase Preview (a chave só existe no .env.local e na Vercel). +# O Supabase responde 401 "Missing SMTP_PASS fields" e derruba a branch de +# preview inteira. Preview mandando e-mail de verdade também não seria +# desejável — gastaria cota do Resend e poderia disparar mensagem real a +# partir de um deploy de teste. +# +# Sem SMTP customizado, o Supabase cai no serviço embutido dele: remetente +# `noreply@mail.app.supabase.io`, template em inglês e poucos e-mails por hora. +# Aceitável em preview/local; inaceitável em produção. +# +# PRODUÇÃO está configurada no dashboard (Authentication → Emails → SMTP +# Settings), com exatamente estes valores — a senha é a mesma `RESEND_API_KEY` +# que já existe no .env.local, não há chave separada: +# # [auth.email.smtp] # enabled = true -# host = "smtp.sendgrid.net" -# port = 587 -# user = "apikey" -# pass = "env(SENDGRID_API_KEY)" -# admin_email = "admin@email.com" -# sender_name = "Admin" - -# Uncomment to customize email template -# [auth.email.template.invite] -# subject = "You have been invited" -# content_path = "./supabase/templates/invite.html" +# host = "smtp.resend.com" +# port = 465 +# user = "resend" +# pass = "env(RESEND_API_KEY)" +# admin_email = "acesso@atrgrowthbusiness.com.br" +# sender_name = "ATR OS" + +# Templates de e-mail. ⚠️ TAMBÉM COMENTADOS, pelo mesmo motivo do bloco acima — +# e aqui a razão é um beco sem saída do plano gratuito. +# +# 👉 O passo a passo de "o que descomentar quando virarmos Supabase Pro" está em +# supabase/README.md. Não improvisar aqui. +# +# com [auth.email.smtp] ligado → 401 "Missing SMTP_PASS fields" +# (env(RESEND_API_KEY) não existe no preview) +# sem [auth.email.smtp] → 400 "Email template modification is not +# available for free tier projects using the +# default email provider" +# +# Ou seja: no free tier, NENHUMA configuração de e-mail pode morar neste arquivo +# sem derrubar a Supabase Preview. Revisitar se o projeto sair do plano gratuito. +# +# O que NÃO se perde: os HTMLs seguem em supabase/templates/, versionados e +# revisáveis em PR. O que este arquivo faria era só aplicá-los automaticamente — +# e isso nunca chegou a valer para produção mesmo, porque produção é configurada +# pelo dashboard (Authentication → Emails → Templates), colando o conteúdo dos +# arquivos à mão. +# +# ⚠️ Consequência prática: mexeu num HTML de supabase/templates/, tem que colar +# no dashboard. O arquivo sozinho não muda o e-mail que o cliente recebe. +# +# Só existem templates para os dois fluxos que o produto realmente dispara: +# recovery → /esqueci-senha (resetPasswordForEmail) +# email_change → ConfigTab.tsx:96 (updateUser({ email })) +# +# [auth.email.template.recovery] +# subject = "Criar uma nova senha — ATR OS" +# content_path = "./supabase/templates/recovery.html" +# +# [auth.email.template.email_change] +# subject = "Confirme a troca de e-mail — ATR OS" +# content_path = "./supabase/templates/email_change.html" [auth.sms] # Allow/disallow new user signups via SMS to your project. diff --git a/supabase/templates/email_change.html b/supabase/templates/email_change.html new file mode 100644 index 00000000..e0cb87b6 --- /dev/null +++ b/supabase/templates/email_change.html @@ -0,0 +1,115 @@ + + + + + + + Confirmar troca de e-mail + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ ATR OS +

+

+ Gestão Financeira Empresarial +

+
+

+ Confirmar troca de e-mail +

+

+ Foi pedida a troca do e-mail de acesso da sua conta no ATR OS. +

+ + + + + + + +
+ De: + {{ .Email }} +
+ Para: + {{ .NewEmail }} +
+
+ + Confirmar a troca + +
+

+ Se o botão não funcionar, copie e cole este endereço no navegador: +

+

+ {{ .SiteURL }}/auth/callback?token_hash={{ .TokenHash }}&type=email_change&next=/perfil +

+
+
+

+ Este e-mail foi enviado para os dois endereços. + A troca só vale depois que a confirmação for feita nos dois. +

+

+ Não foi você? Não clique em nada e fale com a gente — + enquanto ninguém confirmar, o acesso continua no endereço atual. +

+
+
+

+ ATR OS — Gestão Financeira Empresarial +

+

+ Este e-mail foi enviado automaticamente pelo ATR OS. +

+
+
+ + diff --git a/supabase/templates/recovery.html b/supabase/templates/recovery.html new file mode 100644 index 00000000..1e61e66a --- /dev/null +++ b/supabase/templates/recovery.html @@ -0,0 +1,116 @@ + + + + + + + Criar uma nova senha + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ ATR OS +

+

+ Gestão Financeira Empresarial +

+
+

+ Criar uma nova senha +

+

+ Recebemos um pedido para redefinir a senha da conta + {{ .Email }}. +

+

+ Clique no botão abaixo para escolher uma nova. +

+
+ + Criar minha senha + +
+

+ Se o botão não funcionar, copie e cole este endereço no navegador: +

+

+ {{ .SiteURL }}/auth/callback?token_hash={{ .TokenHash }}&type=recovery&next=/alterar-senha +

+
+
+

+ O link vale por 1 hora e só pode ser usado uma vez. + Depois disso, é só pedir outro na tela de login. +

+

+ Não foi você? Pode ignorar este e-mail — + sua senha continua a mesma enquanto o link não for usado. +

+
+
+

+ ATR OS — Gestão Financeira Empresarial +

+

+ Este e-mail foi enviado automaticamente pelo ATR OS. +

+
+
+ + diff --git a/tsconfig.json b/tsconfig.json index 1153242a..bafddbf0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,5 +31,17 @@ "**/*.mts", "tailwind.config.mjs" ], - "exclude": ["node_modules"] + // `include` acima varre `**/*.ts` da raiz — o que arrasta diretórios + // GITIGNORED pro type-check. Efeito: `next build` compila com sucesso e + // depois falha no passo TypeScript apontando pra arquivo que nem está no + // repositório. O erro tem cara de regressão do que você acabou de escrever, + // e não é: a Vercel nunca vê, porque lá esses diretórios não existem. + // + // _local scratch do Lucas (scripts soltos, imports quebrados) + // .claude/worktrees worktrees de validação — cada uma é uma CÓPIA INTEIRA + // do repo; sem excluir, o tsc checa o projeto duas vezes + // + // Nada disso é código de produção. Se um arquivo daqui precisar ser checado, + // o lugar dele é `scripts/`. + "exclude": ["node_modules", "_local", ".claude/worktrees"] }