REFACTOR: Padronizado auth e services do frontend

- Centralizado client HTTP em apiClient
- Padronizado parse de resposta e tratamento de erro
- Mantido Bearer Token via interceptor global de fetch
- Validado JWT pelo campo exp antes de autenticar sessão
- Removidos fallbacks mockados de usuário
- Bloqueado fluxo legado de login por query string
- Preservada rota original após redirecionamento para login
- Protegida rota /admin/whatsapp por perfil admin
- Atualizada wiki frontend de autenticação, rotas e services
This commit is contained in:
Rafael Alves Lopes 2026-06-01 17:13:46 -03:00
parent 04971ca82e
commit 8b85971371
15 changed files with 138 additions and 147 deletions

View File

@ -2,7 +2,7 @@
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { BrandMark } from '../../../shared/components/BrandMark'; import { BrandMark } from '../../../shared/components/BrandMark';
import { useViewport } from '../../../shared/hooks/useViewport'; import { useViewport } from '../../../shared/hooks/useViewport';
import { API_BASE_URL } from '../../../shared/services/apiConfig'; import { apiRequest } from '../../../shared/services/apiClient';
import { getCurrentUser } from '../../auth/services/sessionService'; import { getCurrentUser } from '../../auth/services/sessionService';
import { listContactProfiles, saveContactProfile } from '../../chat/services/contactProfileService'; import { listContactProfiles, saveContactProfile } from '../../chat/services/contactProfileService';
import { getAccessOptions } from '../../management/services/adminAccessService'; import { getAccessOptions } from '../../management/services/adminAccessService';
@ -177,19 +177,17 @@ function getUserAreas(user) {
} }
async function listWhatsappTemplates() { async function listWhatsappTemplates() {
const response = await fetch(`${API_BASE_URL}/whatsapp/templates`); return apiRequest('/whatsapp/templates', {
if (!response.ok) throw new Error('Falha ao carregar templates do WhatsApp.'); fallbackMessage: 'Falha ao carregar templates do WhatsApp.',
return response.json(); });
} }
async function startWhatsappAttendance(payload) { async function startWhatsappAttendance(payload) {
const response = await fetch(`${API_BASE_URL}/whatsapp/start-attendance`, { return apiRequest('/whatsapp/start-attendance', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
fallbackMessage: 'Falha ao iniciar atendimento pelo WhatsApp.',
}); });
if (!response.ok) throw new Error('Falha ao iniciar atendimento pelo WhatsApp.');
return response.json();
} }
export function NewAttendancePage({ embedded = false }) { export function NewAttendancePage({ embedded = false }) {

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { import {
getAuthConfig, getAuthConfig,
loginWithAd, loginWithAd,
@ -17,20 +17,22 @@ function decodeAuthPayload(payload) {
export function useLogin() { export function useLogin() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [providers, setProviders] = useState({ ldap: true, microsoft: false }); const [providers, setProviders] = useState({ ldap: true, microsoft: false });
const targetPath = typeof location.state?.from === 'string' ? location.state.from : '/home';
useEffect(() => { useEffect(() => {
if (!window.location.hash.includes('auth=') && isAuthenticated()) { if (!window.location.hash.includes('auth=') && isAuthenticated()) {
navigate('/home', { replace: true }); navigate(targetPath, { replace: true });
return; return;
} }
getAuthConfig() getAuthConfig()
.then((config) => setProviders(config.providers || { ldap: true, microsoft: false })) .then((config) => setProviders(config.providers || { ldap: true, microsoft: false }))
.catch(() => setProviders({ ldap: true, microsoft: false })); .catch(() => setProviders({ ldap: true, microsoft: false }));
}, [navigate]); }, [navigate, targetPath]);
useEffect(() => { useEffect(() => {
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, '')); const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
@ -40,7 +42,7 @@ export function useLogin() {
try { try {
storeAuthSession(decodeAuthPayload(authPayload)); storeAuthSession(decodeAuthPayload(authPayload));
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
navigate('/home', { replace: true }); navigate(targetPath, { replace: true });
return; return;
} catch { } catch {
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
@ -52,7 +54,7 @@ export function useLogin() {
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
setError('Fluxo de login expirado. Tente novamente.'); setError('Fluxo de login expirado. Tente novamente.');
} }
}, [navigate]); }, [navigate, targetPath]);
async function login(credentials) { async function login(credentials) {
setIsSubmitting(true); setIsSubmitting(true);
@ -61,7 +63,7 @@ export function useLogin() {
try { try {
const authResult = await loginWithAd(credentials); const authResult = await loginWithAd(credentials);
storeAuthSession(authResult); storeAuthSession(authResult);
navigate('/home'); navigate(targetPath);
} catch (loginError) { } catch (loginError) {
setError(loginError.message || 'Falha ao autenticar.'); setError(loginError.message || 'Falha ao autenticar.');
} finally { } finally {

View File

@ -1,28 +1,18 @@
import { API_BASE_URL } from '../../../shared/services/apiConfig'; import { API_BASE_URL } from '../../../shared/services/apiConfig';
import { apiRequest } from '../../../shared/services/apiClient';
async function parseJsonResponse(response) {
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(data?.message || 'Não foi possível autenticar.');
}
return data;
}
export async function getAuthConfig() { export async function getAuthConfig() {
const response = await fetch(`${API_BASE_URL}/auth/config`); return apiRequest('/auth/config', {
return parseJsonResponse(response); fallbackMessage: 'Nao foi possivel carregar configuracao de login.',
});
} }
export async function loginWithAd(credentials) { export async function loginWithAd(credentials) {
const response = await fetch(`${API_BASE_URL}/auth/login`, { return apiRequest('/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials), body: JSON.stringify(credentials),
fallbackMessage: 'Nao foi possivel autenticar.',
}); });
return parseJsonResponse(response);
} }
export function startMicrosoftLogin() { export function startMicrosoftLogin() {

View File

@ -1,19 +1,10 @@
import { API_BASE_URL } from '../../../shared/services/apiConfig'; import { apiRequest } from '../../../shared/services/apiClient';
async function request(path, options = {}) { function request(path, options = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, { return apiRequest(path, {
headers: { fallbackMessage: 'Falha ao atualizar presenca do agente',
'Content-Type': 'application/json',
...options.headers,
},
...options, ...options,
}); });
if (!response.ok) {
throw new Error('Falha ao atualizar presenca do agente');
}
return response.json();
} }
export async function getAgentPresence(userId) { export async function getAgentPresence(userId) {

View File

@ -1,23 +1,21 @@
import { API_BASE_URL } from '../../../shared/services/apiConfig'; import { apiRequest } from '../../../shared/services/apiClient';
export async function listContactProfiles() { export async function listContactProfiles() {
const response = await fetch(`${API_BASE_URL}/contacts`); return apiRequest('/contacts', {
if (!response.ok) throw new Error('Falha ao carregar agenda.'); fallbackMessage: 'Falha ao carregar agenda.',
return response.json(); });
} }
export async function getContactProfile(chatId) { export async function getContactProfile(chatId) {
const response = await fetch(`${API_BASE_URL}/contacts/${encodeURIComponent(chatId)}`); return apiRequest(`/contacts/${encodeURIComponent(chatId)}`, {
if (!response.ok) throw new Error('Falha ao carregar contato.'); fallbackMessage: 'Falha ao carregar contato.',
return response.json(); });
} }
export async function saveContactProfile(chatId, payload) { export async function saveContactProfile(chatId, payload) {
const response = await fetch(`${API_BASE_URL}/contacts/${encodeURIComponent(chatId)}`, { return apiRequest(`/contacts/${encodeURIComponent(chatId)}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
fallbackMessage: 'Falha ao salvar contato.',
}); });
if (!response.ok) throw new Error('Falha ao salvar contato.');
return response.json();
} }

View File

@ -1,27 +1,24 @@
import { API_BASE_URL } from '../../../shared/services/apiConfig'; import { apiRequest } from '../../../shared/services/apiClient';
export async function listAgentNotes(userId) { export async function listAgentNotes(userId) {
if (!userId) return []; if (!userId) return [];
const response = await fetch(`${API_BASE_URL}/agent/notes?userId=${encodeURIComponent(userId)}`);
if (!response.ok) throw new Error('Falha ao carregar anotações.'); return apiRequest(`/agent/notes?userId=${encodeURIComponent(userId)}`, {
return response.json(); fallbackMessage: 'Falha ao carregar anotacoes.',
});
} }
export async function createAgentNote(userId, text) { export async function createAgentNote(userId, text) {
const response = await fetch(`${API_BASE_URL}/agent/notes`, { return apiRequest('/agent/notes', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, text }), body: JSON.stringify({ userId, text }),
fallbackMessage: 'Falha ao salvar anotacao.',
}); });
if (!response.ok) throw new Error('Falha ao salvar anotação.');
return response.json();
} }
export async function deleteAgentNote(userId, noteId) { export async function deleteAgentNote(userId, noteId) {
const response = await fetch( return apiRequest(`/agent/notes/${encodeURIComponent(noteId)}?userId=${encodeURIComponent(userId)}`, {
`${API_BASE_URL}/agent/notes/${encodeURIComponent(noteId)}?userId=${encodeURIComponent(userId)}`, method: 'DELETE',
{ method: 'DELETE' }, fallbackMessage: 'Falha ao excluir anotacao.',
); });
if (!response.ok) throw new Error('Falha ao excluir anotação.');
return response.json();
} }

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { API_BASE_URL } from '../../../shared/services/apiConfig'; import { apiRequest } from '../../../shared/services/apiClient';
import { getCurrentUser } from '../../auth/services/sessionService'; import { getCurrentUser } from '../../auth/services/sessionService';
import { listContactProfiles } from '../../chat/services/contactProfileService'; import { listContactProfiles } from '../../chat/services/contactProfileService';
import { DataPanel } from './DataPanel'; import { DataPanel } from './DataPanel';
@ -54,17 +54,11 @@ function renderPreview(content, variables) {
} }
async function startAttendance(payload) { async function startAttendance(payload) {
const response = await fetch(`${API_BASE_URL}/whatsapp/start-attendance`, { return apiRequest('/whatsapp/start-attendance', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
fallbackMessage: 'Falha ao enviar disparo.',
}); });
if (!response.ok) {
throw new Error('Falha ao enviar disparo.');
}
return response.json();
} }
export function MassMessagePanel({ export function MassMessagePanel({

View File

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { API_BASE_URL, WHATSAPP_SOCKET_URL } from '../../../shared/services/apiConfig'; import { WHATSAPP_SOCKET_URL } from '../../../shared/services/apiConfig';
import { apiRequest } from '../../../shared/services/apiClient';
import { getAuthToken } from '../../auth/services/sessionService'; import { getAuthToken } from '../../auth/services/sessionService';
export const WhatsappAdminPage = () => { export const WhatsappAdminPage = () => {
@ -16,8 +17,9 @@ export const WhatsappAdminPage = () => {
socket.on('connect', () => { socket.on('connect', () => {
console.log('Connected to WhatsApp WebSocket'); console.log('Connected to WhatsApp WebSocket');
fetch(`${API_BASE_URL}/whatsapp/status`) apiRequest('/whatsapp/status', {
.then((response) => response.json()) fallbackMessage: 'Falha ao consultar status do WhatsApp.',
})
.then((data) => setStatus(data.status)) .then((data) => setStatus(data.status))
.catch(console.error); .catch(console.error);
}); });

View File

@ -1,26 +1,10 @@
import { API_BASE_URL } from '../../../shared/services/apiConfig'; import { apiRequest } from '../../../shared/services/apiClient';
async function request(path, options = {}) { function request(path, options = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, { return apiRequest(path, {
headers: { fallbackMessage: 'Falha ao consultar acessos',
'Content-Type': 'application/json',
...options.headers,
},
...options, ...options,
}); });
if (!response.ok) {
let message = 'Falha ao consultar acessos';
try {
const payload = await response.json();
message = payload?.message || payload?.error || message;
} catch {
// Mantem mensagem padrao.
}
throw new Error(message);
}
return response.json();
} }
export async function getAccessOptions() { export async function getAccessOptions() {

View File

@ -1,28 +1,10 @@
import { API_BASE_URL } from '../../../shared/services/apiConfig'; import { apiRequest } from '../../../shared/services/apiClient';
async function request(path, options = {}) { function request(path, options = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, { return apiRequest(path, {
headers: { fallbackMessage: 'Falha ao consultar base de conhecimento.',
'Content-Type': 'application/json',
...options.headers,
},
...options, ...options,
}); });
if (!response.ok) {
let message = 'Falha ao consultar base de conhecimento.';
try {
const payload = await response.json();
message = Array.isArray(payload?.message)
? payload.message.join(' ')
: payload?.message || payload?.error || message;
} catch {
// Mantem a mensagem padrao quando a API nao devolve JSON.
}
throw new Error(message);
}
return response.json();
} }
export function listRoutingKeywords(areaId) { export function listRoutingKeywords(areaId) {

View File

@ -1,19 +1,10 @@
import { API_BASE_URL } from '../../../shared/services/apiConfig'; import { apiRequest } from '../../../shared/services/apiClient';
async function request(path, options = {}) { function request(path, options = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, { return apiRequest(path, {
headers: { fallbackMessage: 'Falha ao consultar templates.',
'Content-Type': 'application/json',
...options.headers,
},
...options, ...options,
}); });
if (!response.ok) {
throw new Error('Falha ao consultar templates.');
}
return response.json();
} }
export function listTemplates() { export function listTemplates() {

View File

@ -1,9 +1,11 @@
import { Navigate } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { getCurrentUserProfile, isAuthenticated } from '../modules/auth/services/sessionService'; import { getCurrentUserProfile, isAuthenticated } from '../modules/auth/services/sessionService';
export function ProtectedRoute({ children, allowedProfiles = [] }) { export function ProtectedRoute({ children, allowedProfiles = [] }) {
const location = useLocation();
if (!isAuthenticated()) { if (!isAuthenticated()) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />;
} }
if (allowedProfiles.length && !allowedProfiles.includes(getCurrentUserProfile())) { if (allowedProfiles.length && !allowedProfiles.includes(getCurrentUserProfile())) {

View File

@ -1,6 +1,7 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import io from 'socket.io-client'; import io from 'socket.io-client';
import { API_BASE_URL, WHATSAPP_SOCKET_URL } from '../services/apiConfig'; import { WHATSAPP_SOCKET_URL } from '../services/apiConfig';
import { apiRequest } from '../services/apiClient';
import { getAuthToken } from '../../modules/auth/services/sessionService'; import { getAuthToken } from '../../modules/auth/services/sessionService';
export function useWhatsappSocket() { export function useWhatsappSocket() {
@ -27,8 +28,9 @@ export function useWhatsappSocket() {
newSocket.on('connect', () => { newSocket.on('connect', () => {
console.log('Conectado ao WebSocket do WhatsApp'); console.log('Conectado ao WebSocket do WhatsApp');
// Fetch status atual // Fetch status atual
fetch(`${API_BASE_URL}/whatsapp/status`) apiRequest('/whatsapp/status', {
.then(res => res.json()) fallbackMessage: 'Falha ao consultar status do WhatsApp.',
})
.then(data => setStatus(data.status)) .then(data => setStatus(data.status))
.catch(console.error); .catch(console.error);
}); });

View File

@ -0,0 +1,62 @@
import { API_BASE_URL } from './apiConfig';
function buildApiUrl(path) {
if (/^https?:\/\//i.test(path)) {
return path;
}
return `${API_BASE_URL}${path}`;
}
async function parseResponse(response) {
const text = await response.text();
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
return text;
}
}
function resolveErrorMessage(payload, fallbackMessage) {
if (Array.isArray(payload?.message)) {
return payload.message.join(' ');
}
return payload?.message || payload?.error || fallbackMessage;
}
export async function apiRequest(path, options = {}) {
const {
headers,
fallbackMessage = 'Falha ao consultar API.',
...requestOptions
} = options;
const requestHeaders = new Headers(headers || {});
const hasBody = requestOptions.body !== undefined && requestOptions.body !== null;
const isFormData = typeof FormData !== 'undefined' && requestOptions.body instanceof FormData;
if (hasBody && !isFormData && !requestHeaders.has('Content-Type')) {
requestHeaders.set('Content-Type', 'application/json');
}
const response = await fetch(buildApiUrl(path), {
...requestOptions,
headers: requestHeaders,
});
const payload = await parseResponse(response);
if (!response.ok) {
const error = new Error(resolveErrorMessage(payload, fallbackMessage));
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}

View File

@ -1,14 +1,10 @@
import { API_BASE_URL } from './apiConfig'; import { API_BASE_URL } from './apiConfig';
import { clearSession } from '../../modules/auth/services/sessionService'; import { clearSession, getAuthToken } from '../../modules/auth/services/sessionService';
function getStoredToken() {
return window.sessionStorage.getItem('authToken');
}
function shouldAttachToken(resource) { function shouldAttachToken(resource) {
const url = typeof resource === 'string' ? resource : resource?.url; const url = typeof resource === 'string' ? resource : resource?.url;
return Boolean(url && url.startsWith(API_BASE_URL) && getStoredToken()); return Boolean(url && url.startsWith(API_BASE_URL) && getAuthToken());
} }
export function installAuthFetchInterceptor() { export function installAuthFetchInterceptor() {
@ -25,7 +21,7 @@ export function installAuthFetchInterceptor() {
const headers = new Headers(options.headers || {}); const headers = new Headers(options.headers || {});
if (!headers.has('Authorization')) { if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${getStoredToken()}`); headers.set('Authorization', `Bearer ${getAuthToken()}`);
} }
const response = await nativeFetch(resource, { const response = await nativeFetch(resource, {