diff --git a/backend/index.js b/backend/index.js index 2ce5ff9..0c5f4a8 100644 --- a/backend/index.js +++ b/backend/index.js @@ -236,8 +236,78 @@ apiRouter.post('/auth/login', async (req, res) => { const valid = await bcrypt.compare(password, user.password_hash); if (!valid) return res.status(401).json({ error: 'Credenciais inválidas.' }); - const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '24h' }); - res.json({ token, user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug } }); + // Generate Access Token (short-lived) + const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '15m' }); + + // Generate Refresh Token (long-lived) + const refreshToken = crypto.randomBytes(40).toString('hex'); + const refreshId = `rt_${crypto.randomUUID().split('-')[0]}`; + + // Store Refresh Token in database (expires in 30 days) + await pool.query( + 'INSERT INTO refresh_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))', + [refreshId, user.id, refreshToken] + ); + + res.json({ + token, + refreshToken, + user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug } + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Refresh Token +apiRouter.post('/auth/refresh', async (req, res) => { + const { refreshToken } = req.body; + if (!refreshToken) return res.status(400).json({ error: 'Refresh token não fornecido.' }); + + try { + // Verifies if the token exists and hasn't expired + const [tokens] = await pool.query( + 'SELECT r.user_id, u.tenant_id, u.role, u.team_id, u.slug, u.status FROM refresh_tokens r JOIN users u ON r.user_id = u.id WHERE r.token = ? AND r.expires_at > NOW()', + [refreshToken] + ); + + if (tokens.length === 0) { + // If invalid, optionally delete the bad token if it's just expired + await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]); + return res.status(401).json({ error: 'Sessão expirada. Faça login novamente.' }); + } + + const user = tokens[0]; + + if (user.status !== 'active') { + await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]); + return res.status(403).json({ error: 'Sua conta está inativa.' }); + } + + // Sliding Expiration: Extend the refresh token's life by another 30 days + await pool.query('UPDATE refresh_tokens SET expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY) WHERE token = ?', [refreshToken]); + + // Issue a new short-lived access token + const newAccessToken = jwt.sign( + { id: user.user_id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, + JWT_SECRET, + { expiresIn: '15m' } + ); + + res.json({ token: newAccessToken }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Logout (Revoke Refresh Token) +apiRouter.post('/auth/logout', async (req, res) => { + const { refreshToken } = req.body; + try { + if (refreshToken) { + await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]); + } + res.json({ message: 'Logout bem-sucedido.' }); } catch (error) { res.status(500).json({ error: error.message }); } @@ -1562,6 +1632,20 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); + // Create refresh_tokens table for persistent sessions + await connection.query(` + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id varchar(36) NOT NULL, + user_id varchar(36) NOT NULL, + token varchar(255) NOT NULL, + expires_at timestamp NOT NULL, + created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY token (token), + KEY user_id (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `); + // Add funnel_id to teams try { await connection.query("ALTER TABLE teams ADD COLUMN funnel_id VARCHAR(36) DEFAULT NULL"); diff --git a/services/dataService.ts b/services/dataService.ts index fb1f61f..7fe15f9 100644 --- a/services/dataService.ts +++ b/services/dataService.ts @@ -6,8 +6,8 @@ import { Attendance, DashboardFilter, User } from '../types'; // Em desenvolvimento, aponta para o localhost:3001 const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3001/api'; -const getHeaders = () => { - const token = localStorage.getItem('ctms_token'); +const getHeaders = (customToken?: string) => { + const token = customToken || localStorage.getItem('ctms_token'); // Evitar enviar "undefined" ou "null" como strings se o localStorage estiver corrompido if (!token || token === 'undefined' || token === 'null') return { 'Content-Type': 'application/json' }; @@ -17,9 +17,76 @@ const getHeaders = () => { }; }; +// Global flag to prevent multiple simultaneous refresh attempts +let isRefreshing = false; +let refreshSubscribers: ((token: string) => void)[] = []; + +const onRefreshed = (token: string) => { + refreshSubscribers.forEach(cb => cb(token)); + refreshSubscribers = []; +}; + +const addRefreshSubscriber = (cb: (token: string) => void) => { + refreshSubscribers.push(cb); +}; + +export const apiFetch = async (url: string, options: RequestInit = {}): Promise => { + let response = await fetch(url, options); + + // If unauthorized, attempt to refresh the token + if (response.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) { + const refreshToken = localStorage.getItem('ctms_refresh_token'); + + if (!refreshToken) { + logout(); + return response; + } + + if (isRefreshing) { + // If a refresh is already in progress, wait for it to finish and retry + return new Promise(resolve => { + addRefreshSubscriber((newToken) => { + options.headers = getHeaders(newToken); + resolve(fetch(url, options)); + }); + }); + } + + isRefreshing = true; + + try { + const refreshResponse = await fetch(`${API_URL}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }) + }); + + if (!refreshResponse.ok) { + throw new Error('Refresh token invalid'); + } + + const data = await refreshResponse.json(); + localStorage.setItem('ctms_token', data.token); + + // Retry the original request + options.headers = getHeaders(data.token); + response = await fetch(url, options); + + onRefreshed(data.token); + } catch (err) { + console.error("Session expired or refresh failed:", err); + logout(); + } finally { + isRefreshing = false; + } + } + + return response; +}; + export const getNotifications = async (): Promise => { try { - const response = await fetch(`${API_URL}/notifications`, { + const response = await apiFetch(`${API_URL}/notifications`, { headers: getHeaders() }); if (!response.ok) throw new Error('Failed to fetch notifications'); @@ -32,7 +99,7 @@ export const getNotifications = async (): Promise => { export const markNotificationAsRead = async (id: string): Promise => { try { - const response = await fetch(`${API_URL}/notifications/${id}`, { + const response = await apiFetch(`${API_URL}/notifications/${id}`, { method: 'PUT', headers: getHeaders() }); @@ -45,7 +112,7 @@ export const markNotificationAsRead = async (id: string): Promise => { export const markAllNotificationsAsRead = async (): Promise => { try { - const response = await fetch(`${API_URL}/notifications/read-all`, { + const response = await apiFetch(`${API_URL}/notifications/read-all`, { method: 'PUT', headers: getHeaders() }); @@ -58,7 +125,7 @@ export const markAllNotificationsAsRead = async (): Promise => { export const deleteNotification = async (id: string): Promise => { try { - const response = await fetch(`${API_URL}/notifications/${id}`, { + const response = await apiFetch(`${API_URL}/notifications/${id}`, { method: 'DELETE', headers: getHeaders() }); @@ -71,7 +138,7 @@ export const deleteNotification = async (id: string): Promise => { export const clearAllNotifications = async (): Promise => { try { - const response = await fetch(`${API_URL}/notifications/clear-all`, { + const response = await apiFetch(`${API_URL}/notifications/clear-all`, { method: 'DELETE', headers: getHeaders() }); @@ -85,7 +152,7 @@ export const clearAllNotifications = async (): Promise => { // --- Funnels Functions --- export const getFunnels = async (tenantId: string): Promise => { try { - const response = await fetch(`${API_URL}/funnels?tenantId=${tenantId}`, { + const response = await apiFetch(`${API_URL}/funnels?tenantId=${tenantId}`, { headers: getHeaders() }); if (!response.ok) throw new Error('Falha ao buscar funis'); @@ -97,7 +164,7 @@ export const getFunnels = async (tenantId: string): Promise => { }; export const createFunnel = async (data: { name: string, tenantId: string }): Promise => { - const response = await fetch(`${API_URL}/funnels`, { + const response = await apiFetch(`${API_URL}/funnels`, { method: 'POST', headers: getHeaders(), body: JSON.stringify(data) @@ -111,7 +178,7 @@ export const createFunnel = async (data: { name: string, tenantId: string }): Pr export const updateFunnel = async (id: string, data: { name?: string, teamIds?: string[] }): Promise => { try { - const response = await fetch(`${API_URL}/funnels/${id}`, { + const response = await apiFetch(`${API_URL}/funnels/${id}`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify(data) @@ -125,7 +192,7 @@ export const updateFunnel = async (id: string, data: { name?: string, teamIds?: export const deleteFunnel = async (id: string): Promise => { try { - const response = await fetch(`${API_URL}/funnels/${id}`, { + const response = await apiFetch(`${API_URL}/funnels/${id}`, { method: 'DELETE', headers: getHeaders() }); @@ -137,7 +204,7 @@ export const deleteFunnel = async (id: string): Promise => { }; export const createFunnelStage = async (funnelId: string, data: any): Promise => { - const response = await fetch(`${API_URL}/funnels/${funnelId}/stages`, { + const response = await apiFetch(`${API_URL}/funnels/${funnelId}/stages`, { method: 'POST', headers: getHeaders(), body: JSON.stringify(data) @@ -151,7 +218,7 @@ export const createFunnelStage = async (funnelId: string, data: any): Promise => { try { - const response = await fetch(`${API_URL}/funnel_stages/${id}`, { + const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify(data) @@ -165,7 +232,7 @@ export const updateFunnelStage = async (id: string, data: any): Promise export const deleteFunnelStage = async (id: string): Promise => { try { - const response = await fetch(`${API_URL}/funnel_stages/${id}`, { + const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, { method: 'DELETE', headers: getHeaders() }); @@ -179,7 +246,7 @@ export const deleteFunnelStage = async (id: string): Promise => { // --- Origins Functions --- export const getOrigins = async (tenantId: string): Promise => { try { - const response = await fetch(`${API_URL}/origins?tenantId=${tenantId}`, { + const response = await apiFetch(`${API_URL}/origins?tenantId=${tenantId}`, { headers: getHeaders() }); if (!response.ok) throw new Error('Falha ao buscar origens'); @@ -191,7 +258,7 @@ export const getOrigins = async (tenantId: string): Promise => { }; export const createOriginGroup = async (data: { name: string, tenantId: string }): Promise => { - const response = await fetch(`${API_URL}/origins`, { + const response = await apiFetch(`${API_URL}/origins`, { method: 'POST', headers: getHeaders(), body: JSON.stringify(data) @@ -205,7 +272,7 @@ export const createOriginGroup = async (data: { name: string, tenantId: string } export const updateOriginGroup = async (id: string, data: { name?: string, teamIds?: string[] }): Promise => { try { - const response = await fetch(`${API_URL}/origins/${id}`, { + const response = await apiFetch(`${API_URL}/origins/${id}`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify(data) @@ -219,7 +286,7 @@ export const updateOriginGroup = async (id: string, data: { name?: string, teamI export const deleteOriginGroup = async (id: string): Promise => { try { - const response = await fetch(`${API_URL}/origins/${id}`, { + const response = await apiFetch(`${API_URL}/origins/${id}`, { method: 'DELETE', headers: getHeaders() }); @@ -231,7 +298,7 @@ export const deleteOriginGroup = async (id: string): Promise => { }; export const createOriginItem = async (groupId: string, data: { name: string, color_class?: string }): Promise => { - const response = await fetch(`${API_URL}/origins/${groupId}/items`, { + const response = await apiFetch(`${API_URL}/origins/${groupId}/items`, { method: 'POST', headers: getHeaders(), body: JSON.stringify(data) @@ -245,7 +312,7 @@ export const createOriginItem = async (groupId: string, data: { name: string, co export const updateOriginItem = async (id: string, data: { name: string, color_class?: string }): Promise => { try { - const response = await fetch(`${API_URL}/origin_items/${id}`, { + const response = await apiFetch(`${API_URL}/origin_items/${id}`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify(data) @@ -259,7 +326,7 @@ export const updateOriginItem = async (id: string, data: { name: string, color_c export const deleteOriginItem = async (id: string): Promise => { try { - const response = await fetch(`${API_URL}/origin_items/${id}`, { + const response = await apiFetch(`${API_URL}/origin_items/${id}`, { method: 'DELETE', headers: getHeaders() }); @@ -273,7 +340,7 @@ export const deleteOriginItem = async (id: string): Promise => { // --- API Keys Functions --- export const getApiKeys = async (tenantId: string): Promise => { try { - const response = await fetch(`${API_URL}/api-keys?tenantId=${tenantId}`, { + const response = await apiFetch(`${API_URL}/api-keys?tenantId=${tenantId}`, { headers: getHeaders() }); if (!response.ok) throw new Error('Falha ao buscar chaves'); @@ -285,7 +352,7 @@ export const getApiKeys = async (tenantId: string): Promise => { }; export const createApiKey = async (data: { name: string, tenantId: string }): Promise => { - const response = await fetch(`${API_URL}/api-keys`, { + const response = await apiFetch(`${API_URL}/api-keys`, { method: 'POST', headers: getHeaders(), body: JSON.stringify(data) @@ -299,7 +366,7 @@ export const createApiKey = async (data: { name: string, tenantId: string }): Pr export const deleteApiKey = async (id: string): Promise => { try { - const response = await fetch(`${API_URL}/api-keys/${id}`, { + const response = await apiFetch(`${API_URL}/api-keys/${id}`, { method: 'DELETE', headers: getHeaders() }); @@ -312,7 +379,7 @@ export const deleteApiKey = async (id: string): Promise => { export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => { try { - const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, { + const response = await apiFetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, { headers: getHeaders() }); if (!response.ok) throw new Error('Search failed'); @@ -336,7 +403,7 @@ export const getAttendances = async (tenantId: string, filter: DashboardFilter): if (filter.funnelStage && filter.funnelStage !== 'all') params.append('funnelStage', filter.funnelStage); if (filter.origin && filter.origin !== 'all') params.append('origin', filter.origin); - const response = await fetch(`${API_URL}/attendances?${params.toString()}`, { + const response = await apiFetch(`${API_URL}/attendances?${params.toString()}`, { headers: getHeaders() }); @@ -357,7 +424,7 @@ export const getUsers = async (tenantId: string): Promise => { const params = new URLSearchParams(); if (tenantId !== 'all') params.append('tenantId', tenantId); - const response = await fetch(`${API_URL}/users?${params.toString()}`, { + const response = await apiFetch(`${API_URL}/users?${params.toString()}`, { headers: getHeaders() }); if (!response.ok) throw new Error('Falha ao buscar usuários'); @@ -371,7 +438,7 @@ export const getUsers = async (tenantId: string): Promise => { export const getUserById = async (id: string): Promise => { try { - const response = await fetch(`${API_URL}/users/${id}`, { + const response = await apiFetch(`${API_URL}/users/${id}`, { headers: getHeaders() }); if (!response.ok) { @@ -393,7 +460,7 @@ export const getUserById = async (id: string): Promise => { export const updateUser = async (id: string, userData: any): Promise => { try { - const response = await fetch(`${API_URL}/users/${id}`, { + const response = await apiFetch(`${API_URL}/users/${id}`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify(userData) @@ -417,7 +484,7 @@ export const uploadAvatar = async (id: string, file: File): Promise => { try { - const response = await fetch(`${API_URL}/users`, { + const response = await apiFetch(`${API_URL}/users`, { method: 'POST', headers: getHeaders(), body: JSON.stringify(userData) @@ -456,7 +523,7 @@ export const createMember = async (userData: any): Promise => { export const deleteUser = async (id: string): Promise => { try { - const response = await fetch(`${API_URL}/users/${id}`, { + const response = await apiFetch(`${API_URL}/users/${id}`, { method: 'DELETE', headers: getHeaders() }); @@ -469,7 +536,7 @@ export const deleteUser = async (id: string): Promise => { export const getAttendanceById = async (id: string): Promise => { try { - const response = await fetch(`${API_URL}/attendances/${id}`, { + const response = await apiFetch(`${API_URL}/attendances/${id}`, { headers: getHeaders() }); if (!response.ok) return undefined; @@ -487,7 +554,7 @@ export const getAttendanceById = async (id: string): Promise => { try { - const response = await fetch(`${API_URL}/tenants`, { + const response = await apiFetch(`${API_URL}/tenants`, { headers: getHeaders() }); if (!response.ok) throw new Error('Falha ao buscar tenants'); @@ -505,7 +572,7 @@ export const getTenants = async (): Promise => { export const getTeams = async (tenantId: string): Promise => { try { - const response = await fetch(`${API_URL}/teams?tenantId=${tenantId}`, { + const response = await apiFetch(`${API_URL}/teams?tenantId=${tenantId}`, { headers: getHeaders() }); if (!response.ok) throw new Error('Falha ao buscar equipes'); @@ -518,7 +585,7 @@ export const getTeams = async (tenantId: string): Promise => { export const createTeam = async (teamData: any): Promise => { try { - const response = await fetch(`${API_URL}/teams`, { + const response = await apiFetch(`${API_URL}/teams`, { method: 'POST', headers: getHeaders(), body: JSON.stringify(teamData) @@ -532,7 +599,7 @@ export const createTeam = async (teamData: any): Promise => { export const updateTeam = async (id: string, teamData: any): Promise => { try { - const response = await fetch(`${API_URL}/teams/${id}`, { + const response = await apiFetch(`${API_URL}/teams/${id}`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify(teamData) @@ -546,7 +613,7 @@ export const updateTeam = async (id: string, teamData: any): Promise => export const deleteTeam = async (id: string): Promise => { try { - const response = await fetch(`${API_URL}/teams/${id}`, { + const response = await apiFetch(`${API_URL}/teams/${id}`, { method: 'DELETE', headers: getHeaders() }); @@ -559,7 +626,7 @@ export const deleteTeam = async (id: string): Promise => { export const createTenant = async (tenantData: any): Promise<{ success: boolean; message?: string }> => { try { - const response = await fetch(`${API_URL}/tenants`, { + const response = await apiFetch(`${API_URL}/tenants`, { method: 'POST', headers: getHeaders(), body: JSON.stringify(tenantData) @@ -575,7 +642,7 @@ export const createTenant = async (tenantData: any): Promise<{ success: boolean; export const updateTenant = async (id: string, tenantData: any): Promise => { try { - const response = await fetch(`${API_URL}/tenants/${id}`, { + const response = await apiFetch(`${API_URL}/tenants/${id}`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify(tenantData) @@ -589,7 +656,7 @@ export const updateTenant = async (id: string, tenantData: any): Promise => { try { - const response = await fetch(`${API_URL}/tenants/${id}`, { + const response = await apiFetch(`${API_URL}/tenants/${id}`, { method: 'DELETE', headers: getHeaders() }); @@ -607,9 +674,23 @@ export let isReloadingForImpersonation = false; export const logout = () => { if (isReloadingForImpersonation) return; // Prevent logout if we are just switching tokens + + const refreshToken = localStorage.getItem('ctms_refresh_token'); + + // Clear local storage synchronously for instant UI update localStorage.removeItem('ctms_token'); + localStorage.removeItem('ctms_refresh_token'); localStorage.removeItem('ctms_user_id'); localStorage.removeItem('ctms_tenant_id'); + + // Attempt to revoke in background + if (refreshToken) { + fetch(`${API_URL}/auth/logout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }) + }).catch(e => console.error("Failed to revoke refresh token", e)); + } }; export const login = async (credentials: any): Promise => { @@ -630,6 +711,7 @@ export const login = async (credentials: any): Promise => { const data = isJson ? await response.json() : null; if (data && data.token) { localStorage.setItem('ctms_token', data.token); + if (data.refreshToken) localStorage.setItem('ctms_refresh_token', data.refreshToken); localStorage.setItem('ctms_user_id', data.user.id); localStorage.setItem('ctms_tenant_id', data.user.tenant_id || ''); } @@ -637,7 +719,7 @@ export const login = async (credentials: any): Promise => { }; export const impersonateTenant = async (tenantId: string): Promise => { - const response = await fetch(`${API_URL}/impersonate/${tenantId}`, { + const response = await apiFetch(`${API_URL}/impersonate/${tenantId}`, { method: 'POST', headers: getHeaders() }); @@ -705,7 +787,7 @@ export const returnToSuperAdmin = (): boolean => { }; export const register = async (userData: any): Promise => { - const response = await fetch(`${API_URL}/auth/register`, { + const response = await apiFetch(`${API_URL}/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(userData) @@ -718,7 +800,7 @@ export const register = async (userData: any): Promise => { }; export const verifyCode = async (data: any): Promise => { - const response = await fetch(`${API_URL}/auth/verify`, { + const response = await apiFetch(`${API_URL}/auth/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) @@ -731,7 +813,7 @@ export const verifyCode = async (data: any): Promise => { }; export const forgotPassword = async (email: string): Promise => { - const response = await fetch(`${API_URL}/auth/forgot-password`, { + const response = await apiFetch(`${API_URL}/auth/forgot-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) @@ -750,7 +832,7 @@ export const forgotPassword = async (email: string): Promise => { }; export const resetPassword = async (password: string, token: string, name?: string): Promise => { - const response = await fetch(`${API_URL}/auth/reset-password`, { + const response = await apiFetch(`${API_URL}/auth/reset-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password, token, name })