From ef6d1582b39d032b224984d78eedeb2113edea21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 16 Mar 2026 14:29:21 -0300 Subject: [PATCH] feat: implement n8n api integration endpoints and api key management - Added api_keys table to database schema. - Added API Key authentication middleware to express router. - Created GET /api/integration/users endpoint for n8n to map agents. - Created POST /api/integration/attendances endpoint to accept webhooks from n8n. - Added UI in UserProfile (for Admins/Owners) to generate, view, and revoke API keys. --- backend/index.js | 205 +++++++++++++++++++++++++++++++++++++++- pages/UserProfile.tsx | 155 +++++++++++++++++++++++++++++- services/dataService.ts | 40 ++++++++ 3 files changed, 397 insertions(+), 3 deletions(-) diff --git a/backend/index.js b/backend/index.js index afcc294..2c9b5e0 100644 --- a/backend/index.js +++ b/backend/index.js @@ -101,11 +101,37 @@ app.use('/uploads', express.static(uploadDir, { const apiRouter = express.Router(); // Middleware de autenticação -const authenticateToken = (req, res, next) => { +const authenticateToken = async (req, res, next) => { // Ignorar rotas de auth if (req.path.startsWith('/auth/')) return next(); const authHeader = req.headers['authorization']; + + // API Key Authentication for n8n/External Integrations + if (authHeader && authHeader.startsWith('Bearer fasto_sk_')) { + const apiKey = authHeader.split(' ')[1]; + try { + const [keys] = await pool.query('SELECT * FROM api_keys WHERE secret_key = ?', [apiKey]); + if (keys.length === 0) return res.status(401).json({ error: 'Chave de API inválida.' }); + + // Update last used timestamp + await pool.query('UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?', [keys[0].id]); + + // Attach a "system/bot" user identity to the request based on the tenant + req.user = { + id: 'bot_integration', + tenant_id: keys[0].tenant_id, + role: 'admin', // Give integration admin privileges within its tenant + is_api_key: true + }; + return next(); + } catch (error) { + console.error('API Key validation error:', error); + return res.status(500).json({ error: 'Erro ao validar chave de API.' }); + } + } + + // Standard JWT Authentication const token = authHeader && authHeader.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Token não fornecido.' }); @@ -305,6 +331,17 @@ apiRouter.post('/auth/reset-password', async (req, res) => { [crypto.randomUUID(), n.id, 'info', 'Novo Membro Ativo', `${name} concluiu o cadastro e já pode acessar o sistema.`, `/users/${user.id}`] ); } + + // If the new user is an admin, notify super_admins too + if (user.role === 'admin') { + const [superAdmins] = await pool.query("SELECT id FROM users WHERE role = 'super_admin'"); + for (const sa of superAdmins) { + await pool.query( + 'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)', + [crypto.randomUUID(), sa.id, 'success', 'Admin Ativo', `O admin ${name} da organização configurou sua conta.`, `/super-admin`] + ); + } + } } } else { // Standard password reset, just update the hash @@ -459,6 +496,18 @@ apiRouter.put('/users/:id', async (req, res) => { 'UPDATE users SET name = ?, bio = ?, email = ?, role = ?, team_id = ?, status = ?, sound_enabled = ? WHERE id = ?', [name || existing[0].name, bio !== undefined ? bio : existing[0].bio, finalEmail, finalRole, finalTeamId || null, finalStatus, finalSoundEnabled, req.params.id] ); + + // Trigger Notification for Team Change + if (finalTeamId && finalTeamId !== existing[0].team_id && existing[0].status === 'active') { + const [team] = await pool.query('SELECT name FROM teams WHERE id = ?', [finalTeamId]); + if (team.length > 0) { + await pool.query( + 'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)', + [crypto.randomUUID(), req.params.id, 'info', 'Novo Time', `Você foi adicionado ao time ${team[0].name}.`, '/'] + ); + } + } + res.json({ message: 'User updated successfully.' }); } catch (error) { console.error('Update user error:', error); res.status(500).json({ error: error.message }); @@ -842,6 +891,145 @@ apiRouter.get('/attendances/:id', async (req, res) => { } catch (error) { res.status(500).json({ error: error.message }); } }); +// --- API Key Management Routes --- +apiRouter.get('/api-keys', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { + try { + const { tenantId } = req.query; + const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; + if (!effectiveTenantId || effectiveTenantId === 'all') return res.json([]); + + const [rows] = await pool.query('SELECT id, name, created_at, last_used_at, CONCAT(SUBSTRING(secret_key, 1, 14), "...") as masked_key FROM api_keys WHERE tenant_id = ?', [effectiveTenantId]); + res.json(rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.post('/api-keys', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { + const { name, tenantId } = req.body; + const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; + try { + const id = `apk_${crypto.randomUUID().split('-')[0]}`; + // Generate a strong, random 32-byte hex string for the secret key + const secretKey = `fasto_sk_${crypto.randomBytes(32).toString('hex')}`; + + await pool.query( + 'INSERT INTO api_keys (id, tenant_id, name, secret_key) VALUES (?, ?, ?, ?)', + [id, effectiveTenantId, name || 'Nova Integração API', secretKey] + ); + + // We only return the actual secret key ONCE during creation. + res.status(201).json({ id, secret_key: secretKey, message: 'Chave criada. Salve-a agora, ela não será exibida novamente.' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.delete('/api-keys/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { + try { + const [existing] = await pool.query('SELECT tenant_id FROM api_keys WHERE id = ?', [req.params.id]); + if (existing.length === 0) return res.status(404).json({ error: 'Chave não encontrada' }); + if (req.user.role !== 'super_admin' && existing[0].tenant_id !== req.user.tenant_id) return res.status(403).json({ error: 'Acesso negado' }); + + await pool.query('DELETE FROM api_keys WHERE id = ?', [req.params.id]); + res.json({ message: 'Chave de API revogada com sucesso.' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// --- External Integration API (n8n) --- +apiRouter.get('/integration/users', requireRole(['admin']), async (req, res) => { + if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' }); + try { + const [rows] = await pool.query( + 'SELECT u.id, u.name, u.email, t.name as team_name FROM users u LEFT JOIN teams t ON u.team_id = t.id WHERE u.tenant_id = ? AND u.status = "active"', + [req.user.tenant_id] + ); + res.json(rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, res) => { + if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' }); + + const { + user_id, + origin, + funnel_stage, + summary, + score, + first_response_time_min, + handling_time_min, + product_requested, + product_sold, + converted, + attention_points, + improvement_points + } = req.body; + + if (!user_id || !origin || !funnel_stage || !summary) { + return res.status(400).json({ error: 'Campos obrigatórios ausentes: user_id, origin, funnel_stage, summary' }); + } + + try { + // Validate user belongs to the API Key's tenant + const [users] = await pool.query('SELECT id FROM users WHERE id = ? AND tenant_id = ? AND status = "active"', [user_id, req.user.tenant_id]); + if (users.length === 0) return res.status(400).json({ error: 'user_id inválido, inativo ou não pertence a esta organização.' }); + + const attId = `att_${crypto.randomUUID().split('-')[0]}`; + await pool.query( + `INSERT INTO attendances ( + id, tenant_id, user_id, summary, score, + first_response_time_min, handling_time_min, + funnel_stage, origin, product_requested, product_sold, + converted, attention_points, improvement_points + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + attId, + req.user.tenant_id, + user_id, + summary, + score || 0, + first_response_time_min || 0, + handling_time_min || 0, + funnel_stage, + origin, + product_requested || null, + product_sold || null, + converted ? 1 : 0, + attention_points ? JSON.stringify(attention_points) : null, + improvement_points ? JSON.stringify(improvement_points) : null + ] + ); + + // Automation Trigger: "Venda Fechada!" (Ganhos) + if (converted) { + // Find the user's manager/admin + const [managers] = await pool.query( + "SELECT id FROM users WHERE tenant_id = ? AND role IN ('admin', 'manager') AND id != ?", + [req.user.tenant_id, user_id] + ); + const [agentInfo] = await pool.query("SELECT name FROM users WHERE id = ?", [user_id]); + const agentName = agentInfo[0]?.name || 'Um agente'; + + for (const m of managers) { + await pool.query( + 'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)', + [crypto.randomUUID(), m.id, 'success', 'Venda Fechada!', `${agentName} converteu um lead em ${funnel_stage}.`, `/attendances/${attId}`] + ); + } + } + + res.status(201).json({ id: attId, message: 'Atendimento registrado com sucesso.' }); + } catch (error) { + console.error('Integration Error:', error); + res.status(500).json({ error: error.message }); + } +}); + // --- Tenant Routes --- apiRouter.get('/tenants', requireRole(['super_admin']), async (req, res) => { try { @@ -1138,6 +1326,21 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); + // Create api_keys table for external integrations (n8n) + await connection.query(` + CREATE TABLE IF NOT EXISTS api_keys ( + id varchar(36) NOT NULL, + tenant_id varchar(36) NOT NULL, + name varchar(255) NOT NULL, + secret_key varchar(255) NOT NULL, + created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at timestamp NULL DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY secret_key (secret_key), + KEY tenant_id (tenant_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/pages/UserProfile.tsx b/pages/UserProfile.tsx index 8ee1878..c341773 100644 --- a/pages/UserProfile.tsx +++ b/pages/UserProfile.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; -import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2, Bell } from 'lucide-react'; -import { getUserById, getTenants, getTeams, updateUser, uploadAvatar } from '../services/dataService'; +import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2, Bell, Key, Trash2, Copy, Plus } from 'lucide-react'; +import { getUserById, getTenants, getTeams, updateUser, uploadAvatar, getApiKeys, createApiKey, deleteApiKey } from '../services/dataService'; import { User, Tenant } from '../types'; export const UserProfile: React.FC = () => { @@ -15,10 +15,27 @@ export const UserProfile: React.FC = () => { const [name, setName] = useState(''); const [bio, setBio] = useState(''); const [email, setEmail] = useState(''); + + // API Keys state + const [apiKeys, setApiKeys] = useState([]); + const [newKeyName, setNewKeyName] = useState(''); + const [isGeneratingKey, setIsGeneratingKey] = useState(false); + const [generatedKey, setGeneratedKey] = useState(null); + const fetchApiKeys = async (tenantId: string) => { + try { + const keys = await getApiKeys(tenantId); + setApiKeys(keys); + } catch (e) { + console.error("Failed to load API keys", e); + } + }; + useEffect(() => { const fetchUserAndTenant = async () => { const storedUserId = localStorage.getItem('ctms_user_id'); + const storedTenantId = localStorage.getItem('ctms_tenant_id'); + if (storedUserId) { try { const fetchedUser = await getUserById(storedUserId); @@ -28,6 +45,12 @@ export const UserProfile: React.FC = () => { setBio(fetchedUser.bio || ''); setEmail(fetchedUser.email); + if (fetchedUser.role === 'admin' || fetchedUser.role === 'super_admin') { + if (storedTenantId) { + fetchApiKeys(storedTenantId); + } + } + // Fetch tenant info const tenants = await getTenants(); const userTenant = tenants.find(t => t.id === fetchedUser.tenant_id); @@ -49,6 +72,36 @@ export const UserProfile: React.FC = () => { fetchUserAndTenant(); }, []); + const handleGenerateApiKey = async () => { + if (!newKeyName.trim() || !tenant) return; + setIsGeneratingKey(true); + try { + const res = await createApiKey({ name: newKeyName, tenantId: tenant.id }); + setGeneratedKey(res.secret_key); + setNewKeyName(''); + fetchApiKeys(tenant.id); + } catch (err: any) { + alert(err.message || 'Erro ao gerar chave.'); + } finally { + setIsGeneratingKey(false); + } + }; + + const handleRevokeApiKey = async (id: string) => { + if (!tenant) return; + if (confirm('Tem certeza? Todas as integrações usando esta chave pararão de funcionar imediatamente.')) { + const success = await deleteApiKey(id); + if (success) { + fetchApiKeys(tenant.id); + } + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + alert('Chave copiada para a área de transferência!'); + }; + const handleAvatarClick = () => { fileInputRef.current?.click(); }; @@ -320,6 +373,104 @@ export const UserProfile: React.FC = () => { + + {/* API Keys Section (Only for Admins/Super Admins) */} + {(user.role === 'admin' || user.role === 'super_admin') && ( +
+
+
+
+
+ +
+
+

Integrações via API (n8n, etc)

+

+ Gerencie as chaves de acesso para que sistemas externos possam enviar dados (como atendimentos) para sua organização. +

+
+
+ + {generatedKey && ( +
+

+ Chave Gerada com Sucesso! +

+

+ Copie esta chave agora. Por motivos de segurança, ela não será exibida novamente. +

+
+ + {generatedKey} + + +
+
+ )} + +
+ setNewKeyName(e.target.value)} + className="flex-1 p-3 border border-zinc-200 dark:border-zinc-800 rounded-lg bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:ring-2 focus:ring-brand-yellow/20 focus:border-brand-yellow sm:text-sm transition-all" + /> + +
+ + {apiKeys.length > 0 ? ( +
+ + + + + + + + + + + {apiKeys.map(key => ( + + + + + + + ))} + +
NomeChaveÚltimo UsoAções
{key.name}{key.masked_key} + {key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Nunca'} + + +
+
+ ) : ( +
+ + Nenhuma chave de API gerada. +
+ )} +
+
+
+ )} ); diff --git a/services/dataService.ts b/services/dataService.ts index 400abb2..74983e5 100644 --- a/services/dataService.ts +++ b/services/dataService.ts @@ -176,6 +176,46 @@ export const deleteFunnelStage = 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}`, { + headers: getHeaders() + }); + if (!response.ok) throw new Error('Falha ao buscar chaves'); + return await response.json(); + } catch (error) { + console.error("API Error (getApiKeys):", error); + return []; + } +}; + +export const createApiKey = async (data: { name: string, tenantId: string }): Promise => { + const response = await fetch(`${API_URL}/api-keys`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify(data) + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Erro ao criar chave de API'); + } + return await response.json(); +}; + +export const deleteApiKey = async (id: string): Promise => { + try { + const response = await fetch(`${API_URL}/api-keys/${id}`, { + method: 'DELETE', + headers: getHeaders() + }); + return response.ok; + } catch (error) { + console.error("API Error (deleteApiKey):", error); + return false; + } +}; + 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)}`, {