Frontend completo (Etapas 11-18) e extensões de backend (Etapas 13 e 18)
Adiciona o frontend Next.js em frontend/ (setup, design system, autenticação/tema, layout/navegação, área master, área tenant completa: clientes, produtos, vendas, recebimentos, whatsapp, e-mail, alertas, equipe e perfil). Estende o backend de forma aditiva e autorizada: cookie httpOnly para refresh token (Etapa 13) e CRUD de equipe + edição de perfil do tenant (Etapa 18, módulo src/modules/equipe/ novo e PUT /tenant/perfil). Detalhes completos em Etapas/V2/*. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly statusCode: number,
|
||||
message: string,
|
||||
public readonly error?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = Omit<RequestInit, 'body'> & {
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gancho de autenticação, preenchido pela Etapa 13. Antes da Etapa 13
|
||||
* existir, o client simplesmente não tem access token para anexar e não
|
||||
* tenta renovar sessão em respostas 401.
|
||||
*/
|
||||
export const authInterceptor: {
|
||||
getAccessToken: () => string | null;
|
||||
onUnauthorized: (() => Promise<boolean>) | null;
|
||||
} = {
|
||||
getAccessToken: () => null,
|
||||
onUnauthorized: null,
|
||||
};
|
||||
|
||||
async function parseErrorResponse(response: Response): Promise<ApiError> {
|
||||
let body: { message?: string | string[]; error?: string } = {};
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
// corpo não é JSON (ex: erro de proxy/gateway) — segue com mensagem genérica
|
||||
}
|
||||
const message = Array.isArray(body.message)
|
||||
? body.message.join(', ')
|
||||
: (body.message ?? response.statusText ?? 'Erro inesperado');
|
||||
return new ApiError(response.status, message, body.error);
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}, isRetry = false): Promise<T> {
|
||||
const { body, headers, ...rest } = options;
|
||||
|
||||
const accessToken = authInterceptor.getAccessToken();
|
||||
// `FormData` (ex: upload de logo, Etapa 18) precisa ir sem `Content-Type`
|
||||
// explícito — o browser define `multipart/form-data; boundary=...`
|
||||
// sozinho — e sem `JSON.stringify` (quebraria o corpo binário).
|
||||
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
|
||||
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
...rest,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...(body !== undefined && !isFormData ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body === undefined ? undefined : isFormData ? (body as FormData) : JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.status === 401 && !isRetry && authInterceptor.onUnauthorized) {
|
||||
const recovered = await authInterceptor.onUnauthorized();
|
||||
if (recovered) {
|
||||
return request<T>(path, options, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw await parseErrorResponse(response);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
get: <T>(path: string, options?: RequestOptions) =>
|
||||
request<T>(path, { ...options, method: 'GET' }),
|
||||
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
||||
request<T>(path, { ...options, method: 'POST', body }),
|
||||
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
||||
request<T>(path, { ...options, method: 'PUT', body }),
|
||||
patch: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
||||
request<T>(path, { ...options, method: 'PATCH', body }),
|
||||
delete: <T>(path: string, options?: RequestOptions) =>
|
||||
request<T>(path, { ...options, method: 'DELETE' }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Chamada "crua" ao `/auth/refresh`, fora do `request()`/`authInterceptor`
|
||||
* de propósito: é ela quem *alimenta* `authInterceptor.onUnauthorized`, e
|
||||
* se passasse pelo mesmo caminho, um 401 na própria renovação chamaria o
|
||||
* interceptor de novo → recursão infinita. Usa só o cookie httpOnly
|
||||
* (`credentials: 'include'`), sem access token.
|
||||
*/
|
||||
export async function fetchRefreshToken(): Promise<{ access_token: string } | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as { access_token: string };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user