Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions db/ESTRUTURA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
|---|---|:-:|---|
Expand Down
189 changes: 145 additions & 44 deletions src/app/alterar-senha/page.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,100 @@
"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();
const { user, profile } = useAuth();

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<string | null>(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;

Expand All @@ -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 = (
<button
type="button"
onClick={() => setMostrarSenha(v => !v)}
className="hover:text-gold-primary transition-colors"
aria-label={mostrarSenha ? "Ocultar senha" : "Mostrar senha"}
>
{mostrarSenha ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
);

return (
<div className="min-h-screen flex items-center justify-center bg-[var(--bg-deep)] p-4">
<div className="min-h-screen flex items-center justify-center bg-deep p-4">
<div className="w-full max-w-md">
<Card
variant="accent"
noPadding
className="p-8 md:p-10 shadow-2xl shadow-[var(--gold-primary)]/5"
className="p-8 md:p-10 shadow-2xl shadow-gold-primary/5"
>
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-[var(--gold-primary)]/20 to-[var(--gold-primary)]/5 border border-[var(--gold-primary)]/30 mb-4 animate-pulse">
<KeyRound className="w-8 h-8 text-[var(--gold-primary)]" />
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-gold-primary/20 to-gold-primary/5 border border-gold-primary/30 mb-4">
<KeyRound className="w-8 h-8 text-gold-primary" />
</div>
<h1 className="text-2xl font-display text-[var(--text-primary)] mb-2">
Defina sua senha
<h1 className="text-2xl font-display text-text-main mb-2">
{primeiroAcesso ? "Defina sua senha" : "Crie uma nova senha"}
</h1>
<p className="text-sm text-[var(--text-muted)]">
Este é seu primeiro acesso. Por segurança, defina uma nova senha pessoal.
<p className="text-sm text-text-muted">
{primeiroAcesso
? "Bem-vindo. Escolha a senha que você vai usar para entrar."
: "Escolha uma senha nova para a sua conta."}
</p>
</div>

Expand All @@ -81,35 +162,55 @@ export default function AlterarSenhaPage() {
</div>
)}

<form onSubmit={handleSubmit} className="space-y-5">
<FormField
label="Nova senha"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
leftIcon={<Lock className="w-4 h-4" />}
/>

<FormField
label="Confirmar senha"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
leftIcon={<Lock className="w-4 h-4" />}
/>
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
<div>
<FormField
label="Nova senha"
type={mostrarSenha ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
onBlur={() => setSenhaTocada(true)}
placeholder="••••••••••"
required
autoComplete="new-password"
autoFocus
leftIcon={<Lock className="w-4 h-4" />}
rightIcon={olho}
error={erroSenha}
hint={erroSenha ? undefined : `Pelo menos ${SENHA_MIN} caracteres, com letras e números.`}
/>
<PasswordStrength senha={password} />
</div>

<div>
<FormField
label="Confirmar senha"
type={mostrarSenha ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••••"
required
autoComplete="new-password"
leftIcon={<Lock className="w-4 h-4" />}
rightIcon={olho}
error={confirmPreenchida && !senhasConferem ? "As senhas não coincidem." : undefined}
/>
{senhasConferem && (
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-status-success">
<Check className="w-3.5 h-3.5" />
As senhas conferem.
</p>
)}
</div>

<Button
type="submit"
isLoading={loading}
disabled={loading}
disabled={!podeSalvar}
className="w-full"
size="lg"
>
Definir senha e entrar
{primeiroAcesso ? "Definir senha e entrar" : "Salvar nova senha"}
</Button>
</form>
</Card>
Expand Down
9 changes: 4 additions & 5 deletions src/app/api/cron/health-score-snapshot/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down
8 changes: 4 additions & 4 deletions src/app/api/cron/monthly-update-reminder/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
}

Expand Down
9 changes: 4 additions & 5 deletions src/app/api/cron/sync-leadpool/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 });
}

Expand Down
Loading
Loading