fix(frontend): renovação automática de sessão — achado real reportado pelo usuário

Achado real: ACCESS_TOKEN_TTL = 15min e apiFetch nunca tentava nenhum refresh —
qualquer reload (ou até só navegar) depois de 15min logado batia 401 em toda Server
Component que chamasse a API, e o erro (ApiError cru, JSON da API) subia sem
tratamento nenhum até virar a tela de erro genérica do Next.js. Reportado pelo
usuário: "Ctrl+F5" depois de um tempo deu "Token de acesso inválido ou expirado".

apps/frontend/src/middleware.ts (novo) decodifica o exp do access token guardado no
cookie (só leitura, sem verificar assinatura — isso continua sendo a API) e, se
faltar menos de 60s pra vencer, chama POST /auth/refresh antes da Server Component
rodar, gravando o cookie novo tanto na resposta quanto na própria requisição (padrão
documentado do Next.js — sem isso a MESMA requisição que disparou o refresh ainda
leria o cookie velho).

Rede de segurança pro caso do refresh token também ter vencido/sido revogado:
apps/frontend/src/app/error.tsx detecta uma mensagem de sessão expirada, desloga e
manda pro /login sozinho, em vez de mostrar a tela crua de erro. Tem que ficar na
raiz de app/, não dentro de app/app/ ou app/platform/ — error.tsx nunca pega erro do
próprio layout do mesmo segmento (achado testando o primeiro corte).

Testado ponta a ponta com refresh token de verdade: token forjado pra vencer em 20s
+ refresh token real → renovação automática confirmada, página protegida carrega
normal. Com refresh token inválido de propósito → confirmado o fallback: error
boundary detecta, desloga e redireciona pro login sozinho.

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 11:18:24 -03:00
parent a15ea26cb5
commit 7bc4b2e819
4 changed files with 189 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
/** Next.js `error.tsx` global-error boundary por segmento (`/app`,
* `/platform`) — pega qualquer erro não tratado durante o render de uma
* Server Component, inclusive um `ApiError` 401 propagado cru de
* `apiFetch` (achado real: sem isto, um access token vencido — TTL de
* 15min, secao 148 — sem sessão de refresh ativa quebrava com uma tela de
* erro genérica do Next.js em vez de mandar de volta pro login).
* `middleware.ts` já renova o token proativamente antes de vencer; este
* boundary é só a rede de segurança pro caso raro do refresh token também
* ter expirado/sido revogado (30 dias de sessão parada, ou logout em
* outro lugar). */
function isSessionExpired(message: string): boolean {
return /token de acesso|unauthorized|statuscode":401/i.test(message);
}
export function SessionErrorView({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
const router = useRouter();
const expired = isSessionExpired(error.message);
useEffect(() => {
if (!expired) return;
fetch("/api/logout", { method: "POST" }).finally(() => {
router.push("/login");
});
}, [expired, router]);
if (expired) {
return (
<div className="flex min-h-dvh items-center justify-center px-6">
<p className="text-sm text-muted-foreground">Sua sessão expirou redirecionando pro login</p>
</div>
);
}
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 px-6 text-center">
<h1 className="text-lg font-semibold text-foreground">Algo deu errado</h1>
<p className="max-w-md text-sm text-muted-foreground">
Tente de novo se persistir, avise o suporte com o que você estava fazendo quando aconteceu.
</p>
<div className="flex gap-3">
<Button type="button" onClick={reset}>
Tentar de novo
</Button>
<Button type="button" variant="outline" onClick={() => router.push("/login")}>
Voltar ao login
</Button>
</div>
</div>
);
}