fix(frontend): tela de "Trocar senha" no primeiro acesso

Achado real reportado pelo usuário testando: todo usuário criado (tenant novo em
Clientes > Tenants, ou convite em Administração > Usuários) nasce com
mustChangePassword: true (agente.md 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.

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 pede a senha
temporária + nova senha (2x), chama POST /auth/change-password, e reaproveita a mesma
decisão platform/tenant do login normal (/api/post-login).

Testado ponta a ponta com um usuário de teste de verdade (convidado, nunca logado
antes): login → /trocar-senha → senha trocada → /app direto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 10:41:20 -03:00
parent 798aa7b579
commit 4b927e9d9e
8 changed files with 195 additions and 8 deletions

22
TODO.md
View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -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",

View File

@@ -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" });

View File

@@ -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<void>("/auth/change-password", session.accessToken, {
method: "POST",
body: JSON.stringify({ currentPassword, newPassword }),
});
return { ok: true };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}

View File

@@ -0,0 +1,7 @@
import { requireSession } from "@/lib/session";
import { TrocarSenhaView } from "./trocar-senha-view";
export default async function TrocarSenhaPage() {
await requireSession();
return <TrocarSenhaView />;
}

View File

@@ -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<string | null>(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 (
<div className="flex min-h-dvh flex-col items-center justify-center px-6 py-12">
<div className="mx-auto w-full max-w-sm">
<Image src="/branding/b2blogo.png" alt="B2BCall" width={140} height={34} priority className="mb-10" />
<h1 className="text-xl font-semibold text-foreground">Trocar senha</h1>
<p className="mt-1 text-sm text-muted-foreground">
Esta é a senha temporária gerada na criação da sua conta escolha uma nova antes de continuar.
</p>
<form onSubmit={onSubmit} className="mt-8 space-y-4" noValidate>
<div>
<label htmlFor="current-password" className="mb-1.5 block text-sm font-medium text-foreground">
Senha temporária
</label>
<input
id="current-password"
name="current-password"
type="password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(e) => 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="••••••••"
/>
</div>
<div>
<label htmlFor="new-password" className="mb-1.5 block text-sm font-medium text-foreground">
Nova senha
</label>
<input
id="new-password"
name="new-password"
type="password"
autoComplete="new-password"
required
minLength={12}
value={newPassword}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="confirm-password" className="mb-1.5 block text-sm font-medium text-foreground">
Confirmar nova senha
</label>
<input
id="confirm-password"
name="confirm-password"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => 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="••••••••"
/>
</div>
{error && (
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Trocando…" : "Trocar senha e continuar"}
</Button>
</form>
</div>
</div>
);
}