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