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:
22
TODO.md
22
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
|
Regressão: tenant_admin e platform_super_admin continuam vendo
|
||||||
TODO item do próprio menu (sem perder nada)
|
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
|
## Riscos conhecidos
|
||||||
|
|||||||
BIN
apps/frontend/.impeccable/review/trocar-senha-after-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/trocar-senha-after-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
apps/frontend/.impeccable/review/trocar-senha-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/trocar-senha-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -19,14 +19,11 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.mustChangePassword) {
|
// Sessão gravada mesmo com senha pendente de troca (secao 199) — só dá
|
||||||
return NextResponse.json(
|
// pra chamar POST /auth/change-password autenticado, então o cookie
|
||||||
{ message: "Senha precisa ser trocada antes do primeiro acesso (use a API /auth/change-password por enquanto)." },
|
// precisa existir antes de mandar pra /trocar-senha. `mustChangePassword`
|
||||||
{ status: 403 },
|
// devolvido aqui é só o sinal pro client escolher a próxima tela.
|
||||||
);
|
const response = NextResponse.json({ ok: true, mustChangePassword: Boolean(data.mustChangePassword) });
|
||||||
}
|
|
||||||
|
|
||||||
const response = NextResponse.json({ ok: true });
|
|
||||||
response.cookies.set(sessionCookieName(), JSON.stringify({ accessToken: data.accessToken, refreshToken: data.refreshToken }), {
|
response.cookies.set(sessionCookieName(), JSON.stringify({ accessToken: data.accessToken, refreshToken: data.refreshToken }), {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ export default function LoginPage() {
|
|||||||
return;
|
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
|
// 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).
|
// 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" });
|
const postLogin = await fetch("/api/post-login", { method: "POST" });
|
||||||
|
|||||||
31
apps/frontend/src/app/trocar-senha/actions.ts
Normal file
31
apps/frontend/src/app/trocar-senha/actions.ts
Normal 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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/frontend/src/app/trocar-senha/page.tsx
Normal file
7
apps/frontend/src/app/trocar-senha/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
124
apps/frontend/src/app/trocar-senha/trocar-senha-view.tsx
Normal file
124
apps/frontend/src/app/trocar-senha/trocar-senha-view.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user