Auth
Autenticação no front-end é sempre o mesmo punhado de problemas: onde guardar a sessão, como proteger uma rota, como ler o que tem dentro do token e o que fazer quando ele expira no meio de um deploy. O módulo auth do tempest-react-sdk resolve cada um desses problemas com uma peça pequena e independente — você pega só o que precisa, sem herdar um framework de auth inteiro.
São cinco peças, todas desacopladas entre si:
createAuthStore— fabrica um Zustand store tipado peloTUserdo app.AuthGuard— gate de rota router-agnostic.decodeJWT/isJWTExpired— leitura defensiva de JWTs (sem validação criptográfica).lazyWithRetry—React.lazycom retry de chunk + reload na falha final.createRefreshQueue— coalesce chamadas de refresh concorrentes.
Por que peças soltas em vez de um <AuthProvider> monolítico
Cada app Tempest tem um modelo de usuário, um fluxo de login e um backend diferentes. Um provider monolítico forçaria todos a um único formato. Cinco primitivos compõem livremente: você usa o store sem o guard, o guard sem o JWT decoder, e por aí vai.
Store — createAuthStore<TUser>
O SDK não é dono do seu modelo de usuário. Você passa o TUser e ganha um store Zustand tipado, já com persist configurado.
import { createAuthStore } from "tempest-react-sdk";
type SessionUser = { id: string; name: string; is_admin: boolean };
export const useAuthStore = createAuthStore<SessionUser>({
name: "tempest-app-auth",
storage: "local",
});
// dentro de um componente: leia o estado (re-renderiza quando muda)
function Header() {
const { user, isAuthenticated, logout } = useAuthStore();
return isAuthenticated ? <button onClick={logout}>Sair de {user?.name}</button> : null;
}
// fora do React: escreva pelo getState()
useAuthStore.getState().setSession({
user: { id: "u1", name: "Ana", is_admin: false },
token: "eyJhbGciOi…",
});
useAuthStore.getState().logout();
Persiste em localStorage (default) ou sessionStorage (storage: "session"). Apenas user e token são persistidos (partialize); após o hydrate, isAuthenticated é re-derivado de !!token no onRehydrateStorage.
Opções extras: initialUser, initialToken (úteis para hydration em SSR).
Selecione o mínimo necessário
const isAuth = useAuthStore((s) => s.isAuthenticated) re-renderiza só
quando isAuthenticated muda. Desestruturar o store inteiro
(const { ... } = useAuthStore()) assina todas as fatias e re-renderiza
em qualquer mudança — prefira o seletor em componentes quentes.
Guard — AuthGuard
Router-agnostic: é um if/else puro. Você decide o que renderizar em cada ramo — tipicamente <Outlet /> quando autenticado e <Navigate /> para o redirect.
import { Navigate, Outlet } from "react-router";
import { AuthGuard } from "tempest-react-sdk";
export function ProtectedLayout() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return (
<AuthGuard isAuthenticated={isAuthenticated} fallback={<Navigate to="/login" replace />}>
<Outlet />
</AuthGuard>
);
}
AuthGuard recebe isAuthenticated, children e fallback — sem opinião sobre router. Compõe com guards de role customizados: aninhe um segundo guard dentro do children.
Relação com <RouteGuard> do módulo routing
Se você usa o módulo de roteamento do SDK, o <RouteGuard>
declarativo já cobre o caso comum (when + redirectTo) direto na árvore de
rotas. Use AuthGuard quando precisar de controle imperativo sobre o que
renderiza em cada ramo, ou quando não estiver usando o <AppRouter>.
JWT — decodeJWT / isJWTExpired
Decoder de payload only — não valida a assinatura. Use no client para inspeção/UX (mostrar nome, esconder botão de admin, decidir quando renovar). A autorização real é sempre no backend.
import { decodeJWT, isJWTExpired } from "tempest-react-sdk";
// decodeJWT LANÇA quando o token é malformado — envolva em try/catch
try {
const { header, payload, signature } = decodeJWT(token);
console.log(payload.sub, payload.exp);
} catch {
// token sem 3 segmentos, ou header/payload não-JSON
}
// isJWTExpired nunca lança — token inválido conta como expirado
const expired = isJWTExpired(token, 30); // 30s de leeway → expirado 30s antes do exp
decodeJWT lança; não retorna null
Em um token malformado, decodeJWT lança Error — sempre envolva em
try/catch. Já isJWTExpired é defensivo: qualquer erro de decode é tratado
como "expirado" (true), então é seguro chamar direto. A assinatura é
isJWTExpired(token, leewaySeconds = 0).
Nunca autorize no client
O payload de um JWT é base64 — qualquer um pode lê-lo e forjá-lo. Use
decodeJWT só para UX. Toda decisão de permissão de verdade acontece no
servidor, que valida a assinatura.
Code-splitting — lazyWithRetry
React.lazy com retry de chunk + fallback window.location.reload() na falha final.
Por que existe: após um deploy, usuários com a aba aberta tentam carregar chunks que já foram deletados (404). O retry com backoff geralmente pega a nova versão; o reload resolve quando o index.html em cache também está stale.
import { Suspense } from "react";
import { lazyWithRetry } from "tempest-react-sdk";
import { Spinner } from "tempest-react-sdk";
const Settings = lazyWithRetry(() => import("./Settings"), {
retries: 3, // default
initialDelay: 400, // default — backoff exponencial: 400, 800, 1600ms
reloadOnFinalFailure: true, // default
});
export function SettingsRoute() {
return (
<Suspense fallback={<Spinner />}>
<Settings />
</Suspense>
);
}
Aquecendo o chunk — preload()
O componente devolvido carrega um preload(). Chame no momento em que a rota fica provável, não quando ela é necessária — o hover do link, a abertura do menu que contém ela, o fim do passo anterior — e o chunk chega antes do usuário decidir. O Suspense fallback simplesmente não aparece.
<a href="/settings" onMouseEnter={() => void Settings.preload()}>
Configurações
</a>
O trabalho é compartilhado com o caminho de render: quem disparar primeiro faz o único fetch, e o outro espera a mesma promise. Chamar várias vezes é seguro.
Aquecer várias rotas de uma vez
preload() devolve a promise, então dá para esperar por um conjunto:
await Promise.all([Settings.preload(), Profile.preload()]);
Ou dispare e esqueça — a rejeição já é tratada internamente, então um preload especulativo que ninguém aguarda nunca vira unhandledrejection.
Refresh queue — createRefreshQueue
Coalesce N chamadas de refresh concorrentes em 1 request. Enquanto um refresh está em voo, toda chamada extra recebe a mesma promise; quando ela resolve, todas retomam juntas.
import { createRefreshQueue } from "tempest-react-sdk";
const refresh = createRefreshQueue(
async () => {
const { token } = await api.post<{ token: string }>("/auth/refresh");
useAuthStore.getState().setToken(token);
},
{ getToken: () => useAuthStore.getState().token },
);
// 5 requests com 401 simultâneos → 1 único refresh, todos retomam:
await Promise.all([refresh(), refresh(), refresh(), refresh(), refresh()]);
Compartilhar a promise sozinho não colapsa uma rajada — passe o getToken
Uma página que dispara várias requests de uma vez recebe vários 401 de volta, mas eles não chegam na mesma janela: os retardatários resolvem depois do primeiro refresh já ter terminado, não encontram promise nenhuma pra entrar, e cada um começa outra — rotacionando um token que já está novo. Medido contra um backend de mentira, cinco requests expiradas concorrentes gastaram dois refreshes, não um.
O getToken fecha essa brecha: a fila lembra qual token o último refresh instalou, e uma chamada que encontra esse mesmo token ainda no lugar retorna na hora, porque o refresh que ela ia fazer já aconteceu. Aí as mesmas cinco requests gastam exatamente um refresh — e vinte também.
Plugue direto no createApiClient
Passe a fila como refresh no cliente HTTP — ele a chama em todo 401 e a coalescência acontece de graça, evitando uma tempestade de refreshes paralelos derrubando seu endpoint de auth.
Use junto com createApiClient:
const refresh = createRefreshQueue(async () => {
await AuthService.refresh();
});
const api = createApiClient({
baseURL: "...",
refresh, // chamado em 401 — coalesce concorrentes
onUnauthorized: () => useAuthStore.getState().logout(),
});
Exemplo completo — login, guard e refresh juntos
Um app real costura as cinco peças. Este é o esqueleto completo e copiável:
// auth-store.ts
import { createAuthStore } from "tempest-react-sdk";
export type SessionUser = { id: string; name: string; email: string };
export const useAuthStore = createAuthStore<SessionUser>({
name: "tempest-app-auth",
storage: "local",
});
// api.ts
import { createApiClient, createRefreshQueue } from "tempest-react-sdk";
import { useAuthStore } from "./auth-store";
const refresh = createRefreshQueue(async () => {
const res = await fetch("/api/auth/refresh", { method: "POST", credentials: "include" });
const { token } = (await res.json()) as { token: string };
useAuthStore.getState().setToken(token);
});
export const api = createApiClient({
baseURL: "/api",
getToken: () => useAuthStore.getState().token,
refresh, // chamado em 401 → coalesce → retry da request original
onUnauthorized: () => useAuthStore.getState().logout(),
});
// LoginPage.tsx
import { useNavigate } from "react-router";
import { useAuthStore, type SessionUser } from "./auth-store";
import { api } from "./api";
export function LoginPage() {
const navigate = useNavigate();
const setSession = useAuthStore((s) => s.setSession);
async function handleSubmit(email: string, password: string) {
const { user, token } = await api.post<{ user: SessionUser; token: string }>("/auth/login", {
body: { email, password },
});
setSession({ user, token });
navigate("/", { replace: true });
}
return <form onSubmit={(e) => e.preventDefault()}>{/* ...campos... */}</form>;
}
// ProtectedLayout.tsx
import { Navigate, Outlet } from "react-router";
import { AuthGuard } from "tempest-react-sdk";
import { useAuthStore } from "./auth-store";
export function ProtectedLayout() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return (
<AuthGuard isAuthenticated={isAuthenticated} fallback={<Navigate to="/login" replace />}>
<Outlet />
</AuthGuard>
);
}
O fluxo: o login chama setSession, persistindo user + token. O ProtectedLayout lê isAuthenticated do store. Cada request injeta o token via getToken; ao tomar 401, o createApiClient chama a fila de refresh (coalescida) e refaz a request. Se o refresh falhar, onUnauthorized dispara logout() e o guard redireciona para /login.
Resumo
createAuthStore<TUser>— store Zustand tipado e persistido; você é dono doTUser.AuthGuard— if/else router-agnostic; você escolhechildrenefallback.decodeJWTlança em token inválido;isJWTExpiredé defensivo e nunca lança. Só para UX, nunca para autorização.lazyWithRetry— sobrevive a chunks stale pós-deploy com backoff + reload.createRefreshQueue— N refreshes concorrentes viram 1.
Veja também
- Routing —
<RouteGuard>declarativo, alternativa aoAuthGuardna árvore de rotas - HTTP —
getToken: () => useAuthStore.getState().token+refresh: queue - Error Boundary — a falha final do
lazyWithRetryvira umChunkLoadErrorque o boundary captura