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:
56
apps/frontend/src/components/session-error-view.tsx
Normal file
56
apps/frontend/src/components/session-error-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user