feat: migrate api key management to dedicated super admin page
- Extracted API Key generation and management from UserProfile to a new /super-admin/api-keys route. - Added cross-tenant selection in the new ApiKeys page so Super Admins can manage integrations for any organization.
This commit is contained in:
2
App.tsx
2
App.tsx
@@ -5,6 +5,7 @@ import { Dashboard } from './pages/Dashboard';
|
||||
import { UserDetail } from './pages/UserDetail';
|
||||
import { AttendanceDetail } from './pages/AttendanceDetail';
|
||||
import { SuperAdmin } from './pages/SuperAdmin';
|
||||
import { ApiKeys } from './pages/ApiKeys';
|
||||
import { TeamManagement } from './pages/TeamManagement';
|
||||
import { Teams } from './pages/Teams';
|
||||
import { Funnels } from './pages/Funnels';
|
||||
@@ -81,6 +82,7 @@ const App: React.FC = () => {
|
||||
<Route path="/users/:id" element={<AuthGuard><UserDetail /></AuthGuard>} />
|
||||
<Route path="/attendances/:id" element={<AuthGuard><AttendanceDetail /></AuthGuard>} />
|
||||
<Route path="/super-admin" element={<AuthGuard roles={['super_admin']}><SuperAdmin /></AuthGuard>} />
|
||||
<Route path="/super-admin/api-keys" element={<AuthGuard roles={['super_admin']}><ApiKeys /></AuthGuard>} />
|
||||
<Route path="/profile" element={<AuthGuard><UserProfile /></AuthGuard>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, Users, UserCircle, Bell, Search, Menu, X, LogOut,
|
||||
Hexagon, Settings, Building2, Sun, Moon, Loader2, Layers,
|
||||
ChevronLeft, ChevronRight
|
||||
ChevronLeft, ChevronRight, Key
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getAttendances, getUsers, getUserById, logout, searchGlobal,
|
||||
@@ -249,14 +249,14 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
<>
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="pt-2 pb-2 px-4 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest whitespace-nowrap">
|
||||
Super Admin
|
||||
Super Admin
|
||||
</div>
|
||||
)}
|
||||
<SidebarItem to="/super-admin" icon={Building2} label="Organizações" collapsed={isSidebarCollapsed} />
|
||||
<SidebarItem to="/admin/users" icon={Users} label="Usuários Globais" collapsed={isSidebarCollapsed} />
|
||||
<SidebarItem to="/super-admin/api-keys" icon={Key} label="Integrações (API)" collapsed={isSidebarCollapsed} />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
)} </nav>
|
||||
|
||||
{/* User Profile Mini - Now Clickable to Profile */}
|
||||
<div className="p-3 border-t border-zinc-100 dark:border-dark-border space-y-3 shrink-0">
|
||||
|
||||
198
pages/ApiKeys.tsx
Normal file
198
pages/ApiKeys.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Key, Loader2, Plus, Trash2, Copy, CheckCircle2, Building2 } from 'lucide-react';
|
||||
import { getApiKeys, createApiKey, deleteApiKey, getTenants } from '../services/dataService';
|
||||
import { Tenant } from '../types';
|
||||
|
||||
export const ApiKeys: React.FC = () => {
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState<string>('');
|
||||
const [apiKeys, setApiKeys] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
|
||||
const [generatedKey, setGeneratedKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const fetchedTenants = await getTenants();
|
||||
setTenants(fetchedTenants);
|
||||
if (fetchedTenants.length > 0) {
|
||||
const defaultTenant = fetchedTenants.find(t => t.id !== 'system') || fetchedTenants[0];
|
||||
setSelectedTenantId(defaultTenant.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load tenants", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchInitialData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTenantId) {
|
||||
loadApiKeys(selectedTenantId);
|
||||
setGeneratedKey(null);
|
||||
}
|
||||
}, [selectedTenantId]);
|
||||
|
||||
const loadApiKeys = async (tenantId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const keys = await getApiKeys(tenantId);
|
||||
setApiKeys(keys);
|
||||
} catch (e) {
|
||||
console.error("Failed to load API keys", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateApiKey = async () => {
|
||||
if (!newKeyName.trim() || !selectedTenantId) return;
|
||||
setIsGeneratingKey(true);
|
||||
try {
|
||||
const res = await createApiKey({ name: newKeyName, tenantId: selectedTenantId });
|
||||
setGeneratedKey(res.secret_key);
|
||||
setNewKeyName('');
|
||||
loadApiKeys(selectedTenantId);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Erro ao gerar chave.');
|
||||
} finally {
|
||||
setIsGeneratingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeApiKey = async (id: string) => {
|
||||
if (!selectedTenantId) return;
|
||||
if (confirm('Tem certeza? Todas as integrações usando esta chave pararão de funcionar imediatamente.')) {
|
||||
const success = await deleteApiKey(id);
|
||||
if (success) {
|
||||
loadApiKeys(selectedTenantId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
alert('Chave copiada para a área de transferência!');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6 pb-12 transition-colors duration-300">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 tracking-tight">Integrações via API</h1>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 text-sm">Gerencie chaves de API para permitir que sistemas externos como o n8n se conectem a organizações específicas.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-dark-card rounded-2xl border border-zinc-200 dark:border-dark-border shadow-sm overflow-hidden p-6 md:p-8">
|
||||
<div className="flex flex-col md:flex-row gap-6 mb-8">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase tracking-wider mb-2 block">Selecione a Organização</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedTenantId}
|
||||
onChange={(e) => setSelectedTenantId(e.target.value)}
|
||||
className="w-full appearance-none bg-zinc-50 dark:bg-dark-input border border-zinc-200 dark:border-dark-border px-4 py-3 pr-10 rounded-xl text-sm text-zinc-900 dark:text-zinc-100 focus:ring-2 focus:ring-brand-yellow/20 outline-none transition-all cursor-pointer font-medium"
|
||||
>
|
||||
{tenants.filter(t => t.id !== 'system').map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-4 text-zinc-500">
|
||||
<Building2 size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generatedKey && (
|
||||
<div className="mb-8 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl animate-in fade-in zoom-in duration-200">
|
||||
<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 shadow-inner">
|
||||
{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 shadow-sm" 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: Webhook n8n - WhatsApp)"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
className="flex-1 p-3 border border-zinc-200 dark:border-dark-border rounded-xl bg-zinc-50 dark:bg-dark-input 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() || !selectedTenantId}
|
||||
className="px-6 py-3 bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 rounded-xl font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-opacity disabled:opacity-50 shrink-0 shadow-sm"
|
||||
>
|
||||
{isGeneratingKey ? <Loader2 className="animate-spin" size={18} /> : <Plus size={18} />}
|
||||
Gerar Nova Chave
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-8"><Loader2 className="animate-spin text-zinc-400" size={32} /></div>
|
||||
) : apiKeys.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-dark-border">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-zinc-50/80 dark:bg-dark-bg/80 border-b border-zinc-200 dark:border-dark-border">
|
||||
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Nome da Integração</th>
|
||||
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Chave (Mascarada)</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-dark-border/50 bg-white dark:bg-dark-card">
|
||||
{apiKeys.map(key => (
|
||||
<tr key={key.id} className="hover:bg-zinc-50 dark:hover:bg-dark-input/50 transition-colors">
|
||||
<td className="py-3 px-4 text-sm font-medium text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
||||
<Key size={14} className="text-brand-yellow" />
|
||||
{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).toLocaleString() : '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-12 border-2 border-dashed border-zinc-200 dark:border-dark-border rounded-xl text-zinc-500 dark:text-zinc-400 bg-zinc-50/30 dark:bg-dark-bg/30">
|
||||
<Key size={32} className="mx-auto mb-3 opacity-20" />
|
||||
<p className="font-medium text-zinc-600 dark:text-zinc-300">Nenhuma chave de API gerada</p>
|
||||
<p className="text-xs mt-1">Crie uma nova chave para conectar sistemas externos a esta organização.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
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 { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2, Bell } from 'lucide-react';
|
||||
import { getUserById, getTenants, getTeams, updateUser, uploadAvatar } from '../services/dataService';
|
||||
import { User, Tenant } from '../types';
|
||||
|
||||
export const UserProfile: React.FC = () => {
|
||||
@@ -16,25 +16,9 @@ export const UserProfile: React.FC = () => {
|
||||
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 {
|
||||
@@ -45,12 +29,6 @@ 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);
|
||||
@@ -72,36 +50,6 @@ 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();
|
||||
};
|
||||
@@ -373,104 +321,6 @@ 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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user