feat: implement n8n api integration endpoints and api key management
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m6s

- 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.
This commit is contained in:
Cauê Faleiros
2026-03-16 14:29:21 -03:00
parent 2ae0e9fdac
commit ef6d1582b3
3 changed files with 397 additions and 3 deletions

View File

@@ -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<any[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
const [generatedKey, setGeneratedKey] = useState<string | null>(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 = () => {
</form>
</div>
</div>
{/* API Keys Section (Only for Admins/Super Admins) */}
{(user.role === 'admin' || user.role === 'super_admin') && (
<div className="lg:col-span-3">
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border border-zinc-200 dark:border-zinc-800 overflow-hidden transition-colors">
<div className="p-6 md:p-8">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-xl">
<Key size={24} />
</div>
<div>
<h2 className="text-xl font-bold text-zinc-900 dark:text-zinc-100">Integrações via API (n8n, etc)</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Gerencie as chaves de acesso para que sistemas externos possam enviar dados (como atendimentos) para sua organização.
</p>
</div>
</div>
{generatedKey && (
<div className="mb-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl">
<h4 className="font-bold text-green-800 dark:text-green-300 flex items-center gap-2 mb-2">
<CheckCircle2 size={18} /> Chave Gerada com Sucesso!
</h4>
<p className="text-sm text-green-700 dark:text-green-400 mb-3">
Copie esta chave agora. Por motivos de segurança, ela <strong>não</strong> será exibida novamente.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-white dark:bg-zinc-950 p-3 rounded-lg text-sm font-mono text-zinc-800 dark:text-zinc-200 border border-green-200 dark:border-green-800 break-all">
{generatedKey}
</code>
<button onClick={() => copyToClipboard(generatedKey)} className="p-3 bg-white dark:bg-zinc-950 border border-green-200 dark:border-green-800 rounded-lg hover:bg-green-100 dark:hover:bg-zinc-800 text-green-700 dark:text-green-400 transition-colors" title="Copiar">
<Copy size={18} />
</button>
</div>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 mb-8">
<input
type="text"
placeholder="Nome da integração (ex: n8n WhatsApp)"
value={newKeyName}
onChange={(e) => 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"
/>
<button
onClick={handleGenerateApiKey}
disabled={isGeneratingKey || !newKeyName.trim()}
className="px-6 py-3 bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 rounded-lg font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-opacity disabled:opacity-50 shrink-0"
>
{isGeneratingKey ? <Loader2 className="animate-spin" size={18} /> : <Plus size={18} />}
Gerar Chave
</button>
</div>
{apiKeys.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-zinc-200 dark:border-zinc-800">
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Nome</th>
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Chave</th>
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Último Uso</th>
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800/50">
{apiKeys.map(key => (
<tr key={key.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors">
<td className="py-3 px-4 text-sm font-medium text-zinc-900 dark:text-zinc-100">{key.name}</td>
<td className="py-3 px-4 text-sm font-mono text-zinc-500 dark:text-zinc-400">{key.masked_key}</td>
<td className="py-3 px-4 text-xs text-zinc-500 dark:text-zinc-400">
{key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Nunca'}
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => handleRevokeApiKey(key.id)}
className="p-2 text-zinc-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors inline-flex"
title="Revogar Chave"
>
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center p-8 border border-dashed border-zinc-200 dark:border-zinc-800 rounded-xl text-zinc-500 dark:text-zinc-400">
<Key size={32} className="mx-auto mb-3 opacity-20" />
Nenhuma chave de API gerada.
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
);