diff --git a/TODO.md b/TODO.md index a551552..4149c0e 100644 --- a/TODO.md +++ b/TODO.md @@ -1735,6 +1735,28 @@ secao 96-103, 124, 169) + achado real de autorização em `/ai/models` Regressão: tenant_admin e platform_super_admin continuam vendo TODO item do próprio menu (sem perder nada) +## PHASE 49 — Tela de "Trocar senha" no primeiro acesso (agente.md secao +199) — achado real reportado pelo usuário testando +- [x] achado real: todo usuário criado (tenant novo em Clientes > Tenants, + ou convite em Administração > Usuários) nasce com + `mustChangePassword: true` (secao 199) — mas `POST /api/login` + simplesmente bloqueava com "use a API /auth/change-password por + enquanto", sem nenhuma tela pra fazer isso. Todo primeiro login de + qualquer conta nova batia nessa parede. Descoberto pelo usuário + tentando logar num tenant que ele mesmo criou +- [x] Corrigido: `POST /api/login` agora grava o cookie de sessão mesmo + com `mustChangePassword: true` (única forma de chamar + `/auth/change-password` autenticado depois) e devolve o flag pro + client, que manda pra `/trocar-senha` em vez de mostrar erro. Tela + nova: senha temporária + nova senha (2x, valida no client que + batem e que tem 12+ caracteres) → `POST /auth/change-password` → + mesma decisão platform/tenant do login normal (`/api/post-login`, + reaproveitado) +- [x] Testado ponta a ponta com um usuário de teste de verdade (convidado, + nunca logado antes): login → `/trocar-senha` → senha trocada → + `/app` direto, sem passar pela tela de login de novo. Usuário de + teste removido no final + --- ## Riscos conhecidos diff --git a/apps/frontend/.impeccable/review/trocar-senha-after-desktop.png b/apps/frontend/.impeccable/review/trocar-senha-after-desktop.png new file mode 100644 index 0000000..06a7e92 Binary files /dev/null and b/apps/frontend/.impeccable/review/trocar-senha-after-desktop.png differ diff --git a/apps/frontend/.impeccable/review/trocar-senha-desktop.png b/apps/frontend/.impeccable/review/trocar-senha-desktop.png new file mode 100644 index 0000000..b3869e1 Binary files /dev/null and b/apps/frontend/.impeccable/review/trocar-senha-desktop.png differ diff --git a/apps/frontend/src/app/api/login/route.ts b/apps/frontend/src/app/api/login/route.ts index c751fe8..e7387e6 100644 --- a/apps/frontend/src/app/api/login/route.ts +++ b/apps/frontend/src/app/api/login/route.ts @@ -19,14 +19,11 @@ export async function POST(request: Request) { const data = await res.json(); - if (data.mustChangePassword) { - return NextResponse.json( - { message: "Senha precisa ser trocada antes do primeiro acesso (use a API /auth/change-password por enquanto)." }, - { status: 403 }, - ); - } - - const response = NextResponse.json({ ok: true }); + // Sessão gravada mesmo com senha pendente de troca (secao 199) — só dá + // pra chamar POST /auth/change-password autenticado, então o cookie + // precisa existir antes de mandar pra /trocar-senha. `mustChangePassword` + // devolvido aqui é só o sinal pro client escolher a próxima tela. + const response = NextResponse.json({ ok: true, mustChangePassword: Boolean(data.mustChangePassword) }); response.cookies.set(sessionCookieName(), JSON.stringify({ accessToken: data.accessToken, refreshToken: data.refreshToken }), { httpOnly: true, sameSite: "lax", diff --git a/apps/frontend/src/app/login/page.tsx b/apps/frontend/src/app/login/page.tsx index a93371f..4d101e4 100644 --- a/apps/frontend/src/app/login/page.tsx +++ b/apps/frontend/src/app/login/page.tsx @@ -28,6 +28,12 @@ export default function LoginPage() { return; } + const loginBody = await res.json().catch(() => ({ mustChangePassword: false })); + if (loginBody.mustChangePassword) { + router.push("/trocar-senha"); + return; + } + // Decide platform vs tenant (e qual tenant) server-side — nunca no // client, o token de sessão não é legível por JS (ver post-login/route.ts). const postLogin = await fetch("/api/post-login", { method: "POST" }); diff --git a/apps/frontend/src/app/trocar-senha/actions.ts b/apps/frontend/src/app/trocar-senha/actions.ts new file mode 100644 index 0000000..dab4982 --- /dev/null +++ b/apps/frontend/src/app/trocar-senha/actions.ts @@ -0,0 +1,31 @@ +"use server"; + +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; + +function extractErrorMessage(err: unknown): string { + if (err instanceof ApiError) { + try { + const parsed = JSON.parse(err.message); + if (Array.isArray(parsed.message)) return parsed.message.join(" "); + if (typeof parsed.message === "string") return parsed.message; + } catch { + // corpo não era JSON + } + return err.message || "Falha inesperada na API."; + } + return "Falha inesperada. Tente novamente."; +} + +export async function changePassword(currentPassword: string, newPassword: string): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await requireSession(); + try { + await apiFetch("/auth/change-password", session.accessToken, { + method: "POST", + body: JSON.stringify({ currentPassword, newPassword }), + }); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/trocar-senha/page.tsx b/apps/frontend/src/app/trocar-senha/page.tsx new file mode 100644 index 0000000..0b90891 --- /dev/null +++ b/apps/frontend/src/app/trocar-senha/page.tsx @@ -0,0 +1,7 @@ +import { requireSession } from "@/lib/session"; +import { TrocarSenhaView } from "./trocar-senha-view"; + +export default async function TrocarSenhaPage() { + await requireSession(); + return ; +} diff --git a/apps/frontend/src/app/trocar-senha/trocar-senha-view.tsx b/apps/frontend/src/app/trocar-senha/trocar-senha-view.tsx new file mode 100644 index 0000000..bb70162 --- /dev/null +++ b/apps/frontend/src/app/trocar-senha/trocar-senha-view.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState, useTransition, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import Image from "next/image"; +import { Button } from "@/components/ui/button"; +import { changePassword } from "./actions"; + +export function TrocarSenhaView() { + const router = useRouter(); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function onSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + if (newPassword.length < 12) { + setError("A nova senha precisa ter pelo menos 12 caracteres."); + return; + } + if (newPassword !== confirmPassword) { + setError("As duas senhas novas não são iguais."); + return; + } + startTransition(async () => { + const result = await changePassword(currentPassword, newPassword); + if (!result.ok) { + setError(result.error); + return; + } + // Mesma decisão platform/tenant do login normal (post-login/route.ts), + // agora que a senha já foi trocada — o cookie de sessão continua o + // mesmo, só o mustChangePassword no banco virou false. + const postLogin = await fetch("/api/post-login", { method: "POST" }); + const postLoginBody = await postLogin.json().catch(() => ({ redirectTo: "/login" })); + if (!postLogin.ok) { + setError(postLoginBody.message ?? "Senha trocada, mas não foi possível continuar. Tente entrar de novo."); + return; + } + router.push(postLoginBody.redirectTo); + router.refresh(); + }); + } + + return ( +
+
+ B2BCall +

Trocar senha

+

+ Esta é a senha temporária gerada na criação da sua conta — escolha uma nova antes de continuar. +

+ +
+
+ + setCurrentPassword(e.target.value)} + disabled={pending} + className="w-full rounded-md border border-input bg-surface px-3 py-2 text-sm text-foreground outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" + placeholder="••••••••" + /> +
+
+ + setNewPassword(e.target.value)} + disabled={pending} + className="w-full rounded-md border border-input bg-surface px-3 py-2 text-sm text-foreground outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" + placeholder="Pelo menos 12 caracteres" + /> +
+
+ + setConfirmPassword(e.target.value)} + disabled={pending} + className="w-full rounded-md border border-input bg-surface px-3 py-2 text-sm text-foreground outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" + placeholder="••••••••" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+
+
+ ); +}