Files
instrucoes_gerais/clube67/newwhats.local/frontend/pages/admin/plugins.tsx
T

1276 lines
53 KiB
TypeScript

import React, { useEffect, useState, useCallback, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useRouter } from 'next/router'
import { motion, AnimatePresence } from 'framer-motion'
import {
Plug2, CheckCircle2, XCircle, Settings, ChevronDown,
ChevronUp, Loader2, AlertTriangle, Eye, EyeOff, Save,
RefreshCw, ShieldCheck, Package, Database, Trash2, Plus,
Info, MessageSquare, FileText, Image, Users, CreditCard,
Stethoscope, ScanLine, Download, Film,
} from 'lucide-react'
import { useAuth } from '@/context/AuthContext'
import { api } from '@/services/chatApiService'
import { PageHeader, Card, Badge } from '@/components/ui'
// ─── Types ────────────────────────────────────────────────────────────────────
interface ConfigField {
key: string
label: string
type: 'text' | 'password' | 'select' | 'toggle' | 'model_select' | 'provider_chain'
placeholder?: string
description?: string
tooltip?: string
options?: Array<{ label: string; value: string }>
required?: boolean
group?: string
default?: string
}
interface Plugin {
name: string
displayName: string
version: string
description: string
author: string
category: string
active: boolean
canDisable: boolean
dependencies: string[]
configSchema: ConfigField[]
config: Record<string, string>
hooks: { subscribes: string[]; emits: string[] }
}
const CATEGORY_LABELS: Record<string, string> = {
core: 'Core',
business: 'Negócio',
integration: 'Integração',
utility: 'Utilitário',
}
const CATEGORY_COLORS: Record<string, string> = {
core: 'bg-blue-500/10 text-blue-400 border border-blue-500/20',
business: 'bg-green-500/10 text-green-400 border border-green-500/20',
integration: 'bg-purple-500/10 text-purple-400 border border-purple-500/20',
utility: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20',
}
// ─── Modelos conhecidos por provider ─────────────────────────────────────────
const MODELS_BY_PROVIDER: Record<string, { label: string; value: string }[]> = {
openai: [
{ label: 'GPT-4o (melhor qualidade)', value: 'gpt-4o' },
{ label: 'GPT-4o Mini (rápido / barato)', value: 'gpt-4o-mini' },
{ label: 'GPT-4 Turbo', value: 'gpt-4-turbo' },
{ label: 'GPT-3.5 Turbo', value: 'gpt-3.5-turbo' },
],
anthropic: [
{ label: 'Claude Sonnet 4.5 (melhor)', value: 'claude-sonnet-4-5' },
{ label: 'Claude 3.5 Haiku (rápido / barato)', value: 'claude-3-5-haiku-20241022' },
{ label: 'Claude 3.5 Sonnet', value: 'claude-3-5-sonnet-20241022' },
],
gemini: [
{ label: 'Gemini 2.0 Flash (recomendado)', value: 'gemini-2.0-flash' },
{ label: 'Gemini 2.0 Flash Lite (mais barato)', value: 'gemini-2.0-flash-lite' },
{ label: 'Gemini 2.5 Pro (mais poderoso)', value: 'gemini-2.5-pro-preview-03-25' },
{ label: 'Gemini 1.5 Pro', value: 'gemini-1.5-pro-latest' },
],
ollama: [
{ label: 'Llama 3.1 (recomendado)', value: 'llama3.1' },
{ label: 'Llama 3', value: 'llama3' },
{ label: 'Mistral', value: 'mistral' },
{ label: 'Gemma 2', value: 'gemma2' },
{ label: 'Phi-3', value: 'phi3' },
{ label: 'Qwen 2.5', value: 'qwen2.5' },
],
}
// normaliza options que podem vir como string[] ou {label,value}[]
function normalizeOptions(opts: any[]): { label: string; value: string }[] {
return opts.map(o => typeof o === 'string' ? { label: o, value: o } : o)
}
// ─── Provider Chain Field ─────────────────────────────────────────────────────
const PROVIDER_LABELS: Record<string, { label: string; color: string }> = {
openai: { label: 'OpenAI', color: 'text-green-400' },
gemini: { label: 'Gemini', color: 'text-blue-400' },
anthropic: { label: 'Anthropic', color: 'text-purple-400' },
ollama: { label: 'Ollama', color: 'text-yellow-400' },
}
const ALL_PROVIDERS = ['openai', 'gemini', 'anthropic', 'ollama']
function ProviderChainField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const chain = (value || 'openai,gemini,anthropic,ollama')
.split(',').map(s => s.trim()).filter(Boolean)
// Garante que todos os providers existam na lista
const full = [
...chain,
...ALL_PROVIDERS.filter(p => !chain.includes(p)),
]
const move = (idx: number, dir: -1 | 1) => {
const next = [...full]
const swap = idx + dir
if (swap < 0 || swap >= next.length) return
;[next[idx], next[swap]] = [next[swap], next[idx]]
onChange(next.join(','))
}
return (
<div className="space-y-1.5">
{full.map((p, idx) => {
const meta = PROVIDER_LABELS[p] ?? { label: p, color: 'text-slate-400' }
return (
<div key={p} className="flex items-center gap-2 bg-white/[0.03] border border-white/8 rounded-xl px-3 py-2">
<span className="text-[10px] font-black text-slate-700 w-4">{idx + 1}</span>
<span className={`flex-1 text-xs font-bold ${meta.color}`}>{meta.label}</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => move(idx, -1)}
disabled={idx === 0}
className="w-6 h-6 rounded-lg flex items-center justify-center text-slate-600 hover:text-white hover:bg-white/10 disabled:opacity-20 transition-all"
>
<ChevronUp size={12} />
</button>
<button
type="button"
onClick={() => move(idx, 1)}
disabled={idx === full.length - 1}
className="w-6 h-6 rounded-lg flex items-center justify-center text-slate-600 hover:text-white hover:bg-white/10 disabled:opacity-20 transition-all"
>
<ChevronDown size={12} />
</button>
</div>
</div>
)
})}
<p className="text-[10px] text-slate-700 pt-1">
Use para reordenar. Providers sem chave configurada são pulados automaticamente.
</p>
</div>
)
}
// ─── Info Tooltip (portal — escapa do overflow do modal) ─────────────────────
function InfoTooltip({ text }: { text: string }) {
const btnRef = useRef<HTMLButtonElement>(null)
const [rect, setRect] = useState<DOMRect | null>(null)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
const show = () => setRect(btnRef.current?.getBoundingClientRect() ?? null)
const hide = () => setRect(null)
const tooltipLeft = rect ? Math.min(rect.right + 10, window.innerWidth - 270) : 0
const tooltipTop = rect ? rect.top + rect.height / 2 : 0
return (
<span className="inline-flex items-center ml-1.5 align-middle shrink-0">
<button
ref={btnRef}
type="button"
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
className="text-slate-600 hover:text-blue-400 transition-colors focus:outline-none"
tabIndex={-1}
>
<Info size={12} />
</button>
{mounted && rect && createPortal(
<div
style={{
position: 'fixed',
left: tooltipLeft,
top: tooltipTop,
transform: 'translateY(-50%)',
zIndex: 9999,
}}
className="w-64 bg-[#1a1d27] border border-white/15 rounded-xl px-3 py-2.5 text-[11px] text-slate-300 leading-relaxed shadow-2xl pointer-events-none"
>
{text}
</div>,
document.body
)}
</span>
)
}
// ─── Category Buckets Editor ───────────────────────────────────────────────────
const CATEGORY_META: Record<string, { icon: React.ReactNode; label: string; color: string }> = {
whatsapp: { icon: <MessageSquare size={13} />, label: 'WhatsApp', color: 'text-green-400 bg-green-500/10' },
media: { icon: <Film size={13} />, label: 'Media', color: 'text-blue-400 bg-blue-500/10' },
documents: { icon: <FileText size={13} />, label: 'Documents', color: 'text-amber-400 bg-amber-500/10' },
posts: { icon: <Image size={13} />, label: 'Posts', color: 'text-purple-400 bg-purple-500/10' },
cards: { icon: <CreditCard size={13} />, label: 'Cards', color: 'text-pink-400 bg-pink-500/10' },
partners: { icon: <Users size={13} />, label: 'Partners', color: 'text-cyan-400 bg-cyan-500/10' },
cdi: { icon: <Stethoscope size={13} />, label: 'CDI', color: 'text-red-400 bg-red-500/10' },
xrays: { icon: <ScanLine size={13} />, label: 'Xrays', color: 'text-indigo-400 bg-indigo-500/10' },
exports: { icon: <Download size={13} />, label: 'Exports', color: 'text-slate-400 bg-slate-500/10' },
}
function CategoryBucketsEditor({
fields,
form,
setForm,
mainBucket,
}: {
fields: ConfigField[]
form: Record<string, string>
setForm: React.Dispatch<React.SetStateAction<Record<string, string>>>
mainBucket: string
}) {
return (
<div className="space-y-2">
<p className="text-[11px] text-slate-600 leading-relaxed mb-3">
Cada categoria de arquivo pode ser enviada para um bucket dedicado.
Deixe em branco para usar o <span className="text-slate-400 font-mono">{mainBucket || 'Bucket Principal'}</span>.
</p>
{fields.map(field => {
const cat = field.key.replace('category_bucket_', '')
const meta = CATEGORY_META[cat]
const value = form[field.key] ?? ''
return (
<div
key={field.key}
className="flex items-center gap-3 bg-white/[0.02] border border-white/[0.06] rounded-xl px-3 py-2.5"
>
{/* Category badge */}
<div className={`flex items-center gap-1.5 w-28 shrink-0 rounded-lg px-2 py-1 ${meta?.color ?? 'text-slate-400 bg-slate-500/10'}`}>
{meta?.icon}
<span className="text-[11px] font-bold">{meta?.label ?? cat}</span>
</div>
{/* Arrow */}
<span className="text-slate-700 text-xs shrink-0"></span>
{/* Input */}
<input
type="text"
value={value}
onChange={e => setForm(f => ({ ...f, [field.key]: e.target.value }))}
placeholder={mainBucket || field.placeholder || 'bucket principal'}
className="flex-1 bg-transparent text-sm text-white placeholder-slate-700 focus:outline-none min-w-0"
/>
{/* Tooltip */}
{field.tooltip && <InfoTooltip text={field.tooltip} />}
</div>
)
})}
</div>
)
}
// ─── Config Modal ──────────────────────────────────────────────────────────────
const GROUP_META: Record<string, { icon: React.ReactNode; color: string; defaultOpen?: boolean }> = {
'Credenciais Wasabi': { icon: <ShieldCheck size={14} />, color: 'text-emerald-400', defaultOpen: true },
'Armazenamento': { icon: <Database size={14} />, color: 'text-blue-400', defaultOpen: true },
'Avançado': { icon: <Settings size={14} />, color: 'text-slate-400', defaultOpen: false },
'Buckets por Categoria': { icon: <Package size={14} />, color: 'text-purple-400', defaultOpen: true },
'Geral': { icon: <Settings size={14} />, color: 'text-slate-400', defaultOpen: true },
}
function ConfigFieldInput({
field,
value,
onChange,
showSecret,
onToggleSecret,
form,
}: {
field: ConfigField
value: string
onChange: (v: string) => void
showSecret: boolean
onToggleSecret: () => void
form: Record<string, string>
}) {
if (field.type === 'toggle') {
return (
<button
onClick={() => onChange(value === 'true' ? 'false' : 'true')}
className={`w-12 h-6 rounded-full transition-colors ${value === 'true' ? 'bg-brand-500' : 'bg-white/10'}`}
>
<span className={`block w-4 h-4 rounded-full bg-white mx-1 transition-transform ${value === 'true' ? 'translate-x-6' : ''}`} />
</button>
)
}
if (field.type === 'select') {
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
className="w-full bg-[#12151e] border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white focus:outline-none focus:border-brand-500/50"
>
<option value="" className="bg-[#12151e]">Selecionar...</option>
{normalizeOptions(field.options ?? []).map(opt => (
<option key={opt.value} value={opt.value} className="bg-[#12151e]">{opt.label}</option>
))}
</select>
)
}
if (field.type === 'model_select') {
const provider = form['default_provider'] ?? ''
const models = MODELS_BY_PROVIDER[provider] ?? Object.values(MODELS_BY_PROVIDER).flat()
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
className="w-full bg-[#12151e] border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white focus:outline-none focus:border-brand-500/50"
>
<option value="" className="bg-[#12151e]">Auto (padrão do provider)</option>
{models.map(m => (
<option key={m.value} value={m.value} className="bg-[#12151e]">{m.label}</option>
))}
</select>
)
}
if (field.type === 'provider_chain') {
return (
<ProviderChainField
value={value || field.default || 'openai,gemini,anthropic,ollama'}
onChange={onChange}
/>
)
}
// text / password
return (
<div className="relative">
<input
type={field.type === 'password' && !showSecret ? 'password' : 'text'}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={field.placeholder}
className="w-full bg-white/[0.04] border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white placeholder-slate-700 focus:outline-none focus:border-brand-500/40 transition-colors pr-10"
/>
{field.type === 'password' && (
<button
type="button"
onClick={onToggleSecret}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-600 hover:text-white transition-colors"
>
{showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
)}
</div>
)
}
function ConfigGroup({
group,
fields,
form,
setForm,
showSecrets,
setShowSecrets,
}: {
group: string
fields: ConfigField[]
form: Record<string, string>
setForm: React.Dispatch<React.SetStateAction<Record<string, string>>>
showSecrets: Record<string, boolean>
setShowSecrets: React.Dispatch<React.SetStateAction<Record<string, boolean>>>
}) {
const meta = GROUP_META[group] ?? GROUP_META['Geral']
const [open, setOpen] = useState(meta.defaultOpen ?? true)
const isCategoryGroup = group === 'Buckets por Categoria'
return (
<div className="border border-white/[0.07] rounded-2xl overflow-hidden">
{/* Group header */}
<button
type="button"
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-3 bg-white/[0.02] hover:bg-white/[0.04] transition-colors"
>
<div className="flex items-center gap-2.5">
<span className={meta.color}>{meta.icon}</span>
<span className="text-xs font-bold text-slate-300 tracking-wide">{group}</span>
{!meta.defaultOpen && !open && (
<span className="text-[10px] text-slate-600 font-normal">raramente necessário</span>
)}
</div>
<ChevronDown
size={14}
className={`text-slate-600 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
/>
</button>
{/* Group body */}
{open && (
<div className="px-4 py-4 space-y-4 border-t border-white/[0.05]">
{isCategoryGroup ? (
<CategoryBucketsEditor
fields={fields}
form={form}
setForm={setForm}
mainBucket={form['wasabiBucket'] ?? ''}
/>
) : (
fields.map(field => (
<div key={field.key}>
<label className="flex items-center gap-1 text-xs font-semibold text-slate-400 mb-1.5">
<span>{field.label}</span>
{field.required && <span className="text-red-400">*</span>}
{field.tooltip && <InfoTooltip text={field.tooltip} />}
</label>
{field.description && (
<p className="text-[11px] text-slate-600 mb-1.5 leading-relaxed">{field.description}</p>
)}
<ConfigFieldInput
field={field}
value={form[field.key] ?? ''}
onChange={v => setForm(f => ({ ...f, [field.key]: v }))}
showSecret={showSecrets[field.key] ?? false}
onToggleSecret={() => setShowSecrets(s => ({ ...s, [field.key]: !s[field.key] }))}
form={form}
/>
</div>
))
)}
</div>
)}
</div>
)
}
function ConfigModal({ plugin, onClose, onSaved }: {
plugin: Plugin
onClose: () => void
onSaved: () => void
}) {
const [form, setForm] = useState<Record<string, string>>(plugin.config ?? {})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({})
const groups = Array.from(new Set(plugin.configSchema.map(f => f.group ?? 'Geral')))
const handleSave = async () => {
setSaving(true)
setError('')
try {
await api.put(`/api/admin/plugins/${plugin.name}/config`, form)
onSaved()
onClose()
} catch (err: any) {
setError(err?.response?.data?.error ?? 'Erro ao salvar configuração')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.97, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="bg-[#0d1018] border border-white/10 rounded-2xl w-full max-w-lg shadow-2xl flex flex-col max-h-[90vh]"
>
{/* Header — fixed, não rola */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/[0.07] shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-white/5 flex items-center justify-center">
<Settings size={15} className="text-slate-400" />
</div>
<div>
<h2 className="text-white font-bold text-base leading-tight">{plugin.displayName}</h2>
<p className="text-slate-600 text-xs">v{plugin.version}</p>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-xl text-slate-500 hover:text-white hover:bg-white/8 transition-colors"
>
<XCircle size={16} />
</button>
</div>
{/* Body — só esta parte rola */}
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-3">
{groups.map(group => {
const fields = plugin.configSchema.filter(f => (f.group ?? 'Geral') === group)
return (
<ConfigGroup
key={group}
group={group}
fields={fields}
form={form}
setForm={setForm}
showSecrets={showSecrets}
setShowSecrets={setShowSecrets}
/>
)
})}
{error && (
<div className="flex items-center gap-2 text-red-400 bg-red-500/10 border border-red-500/20 rounded-xl px-4 py-3 text-sm">
<AlertTriangle size={15} />
{error}
</div>
)}
</div>
{/* Footer — fixed, não rola */}
<div className="flex items-center justify-between px-6 py-4 border-t border-white/[0.07] shrink-0">
<p className="text-[11px] text-slate-700">
Passe o mouse sobre <Info size={10} className="inline mb-0.5 text-blue-400" /> para ver detalhes de cada campo
</p>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors rounded-xl hover:bg-white/5"
>
Cancelar
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-brand-500 hover:bg-brand-600 text-white text-sm font-semibold rounded-xl transition-colors disabled:opacity-50"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Salvar
</button>
</div>
</div>
</motion.div>
</div>
)
}
// ─── Wasabi Storage Modal ─────────────────────────────────────────────────────
const WASABI_REGIONS = [
{ region: 'us-east-1', endpoint: 's3.wasabisys.com', label: 'US East 1 (N. Virginia)' },
{ region: 'us-east-2', endpoint: 's3.us-east-2.wasabisys.com', label: 'US East 2 (N. Virginia)' },
{ region: 'us-central-1', endpoint: 's3.us-central-1.wasabisys.com', label: 'US Central 1 (Texas)' },
{ region: 'us-west-1', endpoint: 's3.us-west-1.wasabisys.com', label: 'US West 1 (Oregon)' },
{ region: 'eu-central-1', endpoint: 's3.eu-central-1.wasabisys.com', label: 'EU Central 1 (Amsterdam)' },
{ region: 'eu-central-2', endpoint: 's3.eu-central-2.wasabisys.com', label: 'EU Central 2 (Frankfurt)' },
{ region: 'eu-west-1', endpoint: 's3.eu-west-1.wasabisys.com', label: 'EU West 1 (London)' },
{ region: 'eu-west-2', endpoint: 's3.eu-west-2.wasabisys.com', label: 'EU West 2 (Paris)' },
{ region: 'eu-south-1', endpoint: 's3.eu-south-1.wasabisys.com', label: 'EU South 1 (Milan)' },
{ region: 'ap-northeast-1', endpoint: 's3.ap-northeast-1.wasabisys.com', label: 'AP Northeast 1 (Tokyo)' },
{ region: 'ap-northeast-2', endpoint: 's3.ap-northeast-2.wasabisys.com', label: 'AP Northeast 2 (Osaka)' },
{ region: 'ap-southeast-1', endpoint: 's3.ap-southeast-1.wasabisys.com', label: 'AP Southeast 1 (Singapore)' },
{ region: 'ap-southeast-2', endpoint: 's3.ap-southeast-2.wasabisys.com', label: 'AP Southeast 2 (Sydney)' },
]
type FetchStatus = 'idle' | 'loading' | 'ok' | 'error'
interface WasabiBucketItem { name: string; createdAt?: string }
function WasabiStorageModal({ plugin, onClose, onSaved }: {
plugin: Plugin
onClose: () => void
onSaved: () => void
}) {
const cfg = plugin.config ?? {}
// Credenciais
const [accessKey, setAccessKey] = useState(cfg.wasabiAccessKey ?? '')
const [secretKey, setSecretKey] = useState(cfg.wasabiSecretKey ?? '')
const [region, setRegion] = useState(cfg.wasabiRegion ?? 'us-east-1')
const [endpoint, setEndpoint] = useState(cfg.wasabiEndpoint ?? '')
const [showKey, setShowKey] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(!!(cfg.wasabiRegion || cfg.wasabiEndpoint))
// Buckets
const [buckets, setBuckets] = useState<WasabiBucketItem[]>([])
const [selectedBucket, setSelectedBucket] = useState(cfg.wasabiBucket ?? '')
const [fetchStatus, setFetchStatus] = useState<FetchStatus>('idle')
const [fetchError, setFetchError] = useState('')
// Criar bucket
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const [createErr, setCreateErr] = useState('')
// Salvar
const [saving, setSaving] = useState(false)
const [saveErr, setSaveErr] = useState('')
// Auto-busca se já tiver credenciais salvas
useEffect(() => {
if (cfg.wasabiAccessKey && cfg.wasabiSecretKey) fetchBuckets()
}, []) // eslint-disable-line
const fetchBuckets = async () => {
if (!accessKey || !secretKey) return
setFetchStatus('loading')
setFetchError('')
try {
const res = await api.post<{ buckets: WasabiBucketItem[] }>('/api/storage/wasabi/list-buckets', {
accessKey, secretKey,
region: region || undefined,
endpoint: endpoint || undefined,
})
setBuckets(res.data.buckets)
setFetchStatus('ok')
} catch (err: any) {
setFetchStatus('error')
setFetchError(err?.response?.data?.error ?? 'Falha na conexão com a Wasabi')
}
}
const handleCreate = async () => {
if (!newName.trim()) return
setCreating(true)
setCreateErr('')
try {
const res = await api.post<{ name: string }>('/api/storage/wasabi/create-bucket', {
accessKey, secretKey,
region: region || undefined,
endpoint: endpoint || undefined,
name: newName.trim(),
})
await fetchBuckets()
setSelectedBucket(res.data.name)
setNewName('')
} catch (err: any) {
setCreateErr(err?.response?.data?.error ?? 'Erro ao criar bucket')
} finally {
setCreating(false)
}
}
const handleSave = async () => {
if (!selectedBucket) { setSaveErr('Selecione um bucket'); return }
setSaving(true)
setSaveErr('')
try {
const payload: Record<string, string> = {
wasabiAccessKey: accessKey,
wasabiSecretKey: secretKey,
wasabiBucket: selectedBucket,
}
if (region) payload.wasabiRegion = region
if (endpoint) payload.wasabiEndpoint = endpoint
await api.put(`/api/admin/plugins/${plugin.name}/config`, payload)
onSaved()
onClose()
} catch (err: any) {
setSaveErr(err?.response?.data?.error ?? 'Erro ao salvar')
} finally {
setSaving(false)
}
}
const canFetch = accessKey.length > 0 && secretKey.length > 0
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.97, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="bg-[#0d1018] border border-white/10 rounded-2xl w-full max-w-lg shadow-2xl flex flex-col max-h-[90vh]"
>
{/* ── Header ── */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/[0.07] shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-emerald-500/10 flex items-center justify-center">
<Database size={17} className="text-emerald-400" />
</div>
<div>
<h2 className="text-white font-bold text-base leading-tight">Wasabi Storage</h2>
<p className="text-slate-600 text-xs">Armazenamento de mídias do WhatsApp</p>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-xl text-slate-600 hover:text-white hover:bg-white/8 transition-colors"
>
<XCircle size={16} />
</button>
</div>
{/* ── Body (scrollable) ── */}
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-5">
{/* Credenciais */}
<div className="space-y-3">
<p className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">Credenciais Wasabi</p>
<div>
<label className="flex items-center gap-1 text-xs font-semibold text-slate-400 mb-1.5">
Access Key <span className="text-red-400">*</span>
<InfoTooltip text="Chave de acesso pública gerada em Account → Access Keys no painel Wasabi." />
</label>
<input
type="text"
value={accessKey}
onChange={e => setAccessKey(e.target.value)}
placeholder="NK2RNWUWBNGC169FSN1N"
className="w-full bg-white/[0.04] border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white placeholder-slate-700 focus:outline-none focus:border-emerald-500/40 font-mono transition-colors"
/>
</div>
<div>
<label className="flex items-center gap-1 text-xs font-semibold text-slate-400 mb-1.5">
Secret Key <span className="text-red-400">*</span>
<InfoTooltip text="Chave secreta correspondente à Access Key — exibida apenas uma vez no momento da criação no painel Wasabi." />
</label>
<div className="relative">
<input
type={showKey ? 'text' : 'password'}
value={secretKey}
onChange={e => setSecretKey(e.target.value)}
placeholder="●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●"
className="w-full bg-white/[0.04] border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white placeholder-slate-700 focus:outline-none focus:border-emerald-500/40 font-mono transition-colors pr-10"
/>
<button
type="button"
onClick={() => setShowKey(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-600 hover:text-white transition-colors"
>
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
{/* Avançado */}
<button
type="button"
onClick={() => setShowAdvanced(v => !v)}
className="flex items-center gap-1.5 text-[11px] text-slate-600 hover:text-slate-400 transition-colors"
>
<ChevronDown size={12} className={`transition-transform ${showAdvanced ? 'rotate-180' : ''}`} />
Configurações avançadas (region / endpoint)
</button>
{showAdvanced && (
<div className="space-y-3 pl-1">
<div>
<label className="flex items-center gap-1 text-xs font-semibold text-slate-500 mb-1.5">
Região
<InfoTooltip text="Selecione a região onde seu bucket foi criado. O endpoint é preenchido automaticamente." />
</label>
<select
value={region}
onChange={e => {
const r = e.target.value
setRegion(r)
const ep = WASABI_REGIONS.find(x => x.region === r)?.endpoint ?? ''
setEndpoint(ep === 's3.wasabisys.com' ? '' : ep)
}}
className="w-full bg-white/[0.03] border border-white/8 rounded-xl px-3 py-2 text-sm text-white focus:outline-none focus:border-white/20 font-mono appearance-none"
>
{WASABI_REGIONS.map(r => (
<option key={r.region} value={r.region} className="bg-[#13151f]">
{r.region} {r.label}
</option>
))}
</select>
</div>
<div>
<label className="flex items-center gap-1 text-xs font-semibold text-slate-500 mb-1.5">
Endpoint
<InfoTooltip text="Preenchido automaticamente ao selecionar a região. Só altere manualmente se necessário." />
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={endpoint || WASABI_REGIONS.find(x => x.region === region)?.endpoint || 's3.wasabisys.com'}
onChange={e => setEndpoint(e.target.value)}
className="flex-1 bg-white/[0.03] border border-white/8 rounded-xl px-3 py-2 text-sm text-slate-400 placeholder-slate-700 focus:outline-none focus:border-white/20 font-mono"
/>
</div>
</div>
</div>
)}
{/* Botão buscar */}
<button
type="button"
onClick={fetchBuckets}
disabled={!canFetch || fetchStatus === 'loading'}
className="flex items-center gap-2 px-4 py-2.5 bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-400 text-sm font-semibold rounded-xl transition-colors disabled:opacity-40 disabled:cursor-not-allowed w-full justify-center"
>
{fetchStatus === 'loading'
? <><Loader2 size={14} className="animate-spin" /> Conectando...</>
: <><RefreshCw size={14} /> Buscar Buckets</>
}
</button>
{fetchStatus === 'error' && (
<div className="flex items-center gap-2 text-red-400 bg-red-500/10 border border-red-500/20 rounded-xl px-3 py-2.5 text-xs">
<AlertTriangle size={13} />
{fetchError}
</div>
)}
</div>
{/* Lista de buckets */}
{fetchStatus === 'ok' && (
<div className="space-y-3">
<p className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
Buckets disponíveis
<span className="ml-2 text-slate-700 font-normal normal-case"> clique para selecionar</span>
</p>
{buckets.length === 0 ? (
<p className="text-slate-600 text-xs text-center py-4">Nenhum bucket encontrado. Crie um abaixo.</p>
) : (
<div className="space-y-1.5">
{buckets.map(b => {
const active = b.name === selectedBucket
return (
<button
key={b.name}
type="button"
onClick={() => setSelectedBucket(b.name ?? '')}
className={`w-full flex items-center justify-between px-4 py-3 rounded-xl border text-left transition-all ${
active
? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-300'
: 'bg-white/[0.02] border-white/[0.06] text-slate-300 hover:bg-white/[0.05] hover:border-white/15'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 ${active ? 'border-emerald-400' : 'border-slate-600'}`}>
{active && <div className="w-2 h-2 rounded-full bg-emerald-400" />}
</div>
<span className="text-sm font-mono">{b.name}</span>
</div>
{b.createdAt && (
<span className="text-xs text-slate-600 shrink-0 ml-2">
{new Date(b.createdAt).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
)}
</button>
)
})}
</div>
)}
{/* Criar novo bucket */}
<div className="border-t border-white/5 pt-3">
<p className="text-[11px] font-bold text-slate-500 uppercase tracking-widest mb-2">Criar novo bucket</p>
<div className="flex gap-2">
<input
type="text"
value={newName}
onChange={e => setNewName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))}
onKeyDown={e => e.key === 'Enter' && handleCreate()}
placeholder="nome-do-bucket"
className="flex-1 bg-white/[0.03] border border-white/8 rounded-xl px-3 py-2 text-sm text-white placeholder-slate-700 focus:outline-none focus:border-white/20 font-mono"
/>
<button
type="button"
onClick={handleCreate}
disabled={creating || !newName.trim()}
className="flex items-center gap-1.5 px-4 py-2 bg-white/5 hover:bg-white/10 text-slate-300 text-sm font-semibold rounded-xl transition-colors disabled:opacity-40"
>
{creating ? <Loader2 size={13} className="animate-spin" /> : <Plus size={13} />}
Criar
</button>
</div>
{createErr && (
<p className="text-red-400 text-xs mt-1.5 flex items-center gap-1">
<AlertTriangle size={11} /> {createErr}
</p>
)}
</div>
</div>
)}
{/* Bucket selecionado (feedback) */}
{selectedBucket && fetchStatus === 'ok' && (
<div className="flex items-center gap-2 bg-emerald-500/8 border border-emerald-500/20 rounded-xl px-4 py-3">
<CheckCircle2 size={14} className="text-emerald-400 shrink-0" />
<div>
<p className="text-emerald-300 text-xs font-semibold">Bucket ativo selecionado</p>
<p className="text-emerald-400/70 text-xs font-mono">{selectedBucket}</p>
</div>
</div>
)}
{saveErr && (
<div className="flex items-center gap-2 text-red-400 bg-red-500/10 border border-red-500/20 rounded-xl px-3 py-2.5 text-xs">
<AlertTriangle size={13} />
{saveErr}
</div>
)}
</div>
{/* ── Footer ── */}
<div className="flex items-center justify-between px-6 py-4 border-t border-white/[0.07] shrink-0">
<p className="text-[11px] text-slate-700">
{fetchStatus === 'ok'
? `${buckets.length} bucket${buckets.length !== 1 ? 's' : ''} encontrado${buckets.length !== 1 ? 's' : ''}`
: 'Entre as credenciais e clique em Buscar'
}
</p>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors rounded-xl hover:bg-white/5"
>
Cancelar
</button>
<button
onClick={handleSave}
disabled={saving || !selectedBucket || !accessKey || !secretKey}
className="flex items-center gap-2 px-4 py-2 bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 text-sm font-semibold rounded-xl transition-colors disabled:opacity-40 disabled:cursor-not-allowed border border-emerald-500/20"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Salvar
</button>
</div>
</div>
</motion.div>
</div>
)
}
// ─── Wasabi Buckets Panel ─────────────────────────────────────────────────────
interface WasabiBucket {
name: string
createdAt?: string
}
// ─── Plugin Card ───────────────────────────────────────────────────────────────
function PluginCard({ plugin, onToggle, onConfigure, toggling }: {
plugin: Plugin
onToggle: (name: string, enable: boolean) => void
onConfigure: (plugin: Plugin) => void
toggling: string | null
}) {
const [expanded, setExpanded] = useState(false)
const hasConfig = plugin.configSchema.length > 0
const hasHooks = plugin.hooks.subscribes.length > 0 || plugin.hooks.emits.length > 0
return (
<div className={`bg-white/[0.03] border rounded-2xl transition-all ${plugin.active ? 'border-white/10' : 'border-white/5 opacity-60'}`}>
<div className="p-5 flex items-start gap-4">
{/* Icon */}
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${plugin.active ? 'bg-brand-500/15' : 'bg-white/5'}`}>
<Package size={20} className={plugin.active ? 'text-brand-400' : 'text-slate-500'} />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-white font-semibold text-sm">{plugin.displayName}</span>
<span className="text-slate-600 text-xs">v{plugin.version}</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${CATEGORY_COLORS[plugin.category] ?? 'bg-white/5 text-slate-400'}`}>
{CATEGORY_LABELS[plugin.category] ?? plugin.category}
</span>
{!plugin.canDisable && (
<span className="flex items-center gap-1 text-xs text-slate-500">
<ShieldCheck size={11} /> Core
</span>
)}
</div>
<p className="text-slate-500 text-xs mt-1 line-clamp-2">{plugin.description}</p>
{plugin.dependencies.length > 0 && (
<p className="text-slate-600 text-xs mt-1">
Deps: {plugin.dependencies.join(', ')}
</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 shrink-0">
{hasConfig && (
<button
onClick={() => onConfigure(plugin)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white text-xs font-medium rounded-lg transition-colors"
>
<Settings size={13} />
Config
</button>
)}
{plugin.canDisable && (
<button
onClick={() => onToggle(plugin.name, !plugin.active)}
disabled={toggling === plugin.name}
className={`relative w-11 h-6 rounded-full transition-colors shrink-0 disabled:opacity-50 ${plugin.active ? 'bg-brand-500' : 'bg-white/10'}`}
>
{toggling === plugin.name ? (
<Loader2 size={12} className="absolute inset-0 m-auto text-white animate-spin" />
) : (
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all ${plugin.active ? 'left-6' : 'left-1'}`} />
)}
</button>
)}
{(hasHooks || plugin.hooks) && (
<button
onClick={() => setExpanded(e => !e)}
className="text-slate-500 hover:text-white transition-colors"
>
{expanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
)}
</div>
</div>
{/* Expanded: hooks info */}
<AnimatePresence>
{expanded && hasHooks && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="px-5 pb-5 pt-0 border-t border-white/5 mt-0 grid grid-cols-2 gap-4">
{plugin.hooks.subscribes.length > 0 && (
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-2">Escuta</p>
<div className="flex flex-wrap gap-1">
{plugin.hooks.subscribes.map(h => (
<span key={h} className="text-xs bg-blue-500/10 text-blue-400 border border-blue-500/20 px-2 py-0.5 rounded-full">{h}</span>
))}
</div>
</div>
)}
{plugin.hooks.emits.length > 0 && (
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-2">Emite</p>
<div className="flex flex-wrap gap-1">
{plugin.hooks.emits.map(h => (
<span key={h} className="text-xs bg-green-500/10 text-green-400 border border-green-500/20 px-2 py-0.5 rounded-full">{h}</span>
))}
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
type StorageHealthReason = 'ok' | 'not_configured' | 'needs_restart' | 'auth_error' | 'billing_error' | 'bucket_not_found' | 'network_error' | 'unknown'
interface StorageHealthStatus {
healthy: boolean
configured: boolean
reason: StorageHealthReason
message: string
}
const STORAGE_ALERT_CONFIG: Record<StorageHealthReason, { color: string; icon: string; label: string }> = {
ok: { color: 'border-green-500/30 bg-green-500/10 text-green-300', icon: '✓', label: 'Wasabi OK' },
not_configured: { color: 'border-yellow-500/30 bg-yellow-500/10 text-yellow-300', icon: '⚙', label: 'Wasabi não configurado' },
needs_restart: { color: 'border-blue-500/30 bg-blue-500/10 text-blue-300', icon: '↺', label: 'Reinicialização necessária' },
auth_error: { color: 'border-red-500/30 bg-red-500/10 text-red-300', icon: '✕', label: 'Chaves inválidas' },
billing_error: { color: 'border-orange-500/30 bg-orange-500/10 text-orange-300', icon: '!', label: 'Conta Wasabi — verificar pagamento' },
bucket_not_found:{ color: 'border-red-500/30 bg-red-500/10 text-red-300', icon: '✕', label: 'Bucket não encontrado' },
network_error: { color: 'border-red-500/30 bg-red-500/10 text-red-300', icon: '✕', label: 'Sem conexão com Wasabi' },
unknown: { color: 'border-red-500/30 bg-red-500/10 text-red-300', icon: '?', label: 'Erro desconhecido' },
}
export default function PluginsPage() {
const router = useRouter()
const { user, loading: authLoading } = useAuth()
const [plugins, setPlugins] = useState<Plugin[]>([])
const [loading, setLoading] = useState(true)
const [toggling, setToggling] = useState<string | null>(null)
const [configPlugin, setConfigPlugin] = useState<Plugin | null>(null)
const [toast, setToast] = useState<{ msg: string; type: 'ok' | 'err' } | null>(null)
const [storageHealth, setStorageHealth] = useState<StorageHealthStatus | null>(null)
useEffect(() => {
if (!authLoading && user?.role !== 'admin') router.replace('/dashboard')
}, [authLoading, user, router])
const fetchStorageHealth = useCallback(async () => {
try {
const res = await api.get<StorageHealthStatus>('/api/admin/plugins/storage/health')
setStorageHealth(res.data)
} catch {
// silently ignore — storage health is informational only
}
}, [])
const fetchPlugins = useCallback(async () => {
try {
const res = await api.get<Plugin[]>('/api/admin/plugins')
setPlugins(res.data)
} catch {
showToast('Erro ao carregar plugins', 'err')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (!authLoading && user?.role === 'admin') {
fetchPlugins()
fetchStorageHealth()
}
}, [authLoading, user, fetchPlugins, fetchStorageHealth])
const showToast = (msg: string, type: 'ok' | 'err') => {
setToast({ msg, type })
setTimeout(() => setToast(null), 3500)
}
const handleToggle = async (name: string, enable: boolean) => {
setToggling(name)
try {
await api.post(`/api/admin/plugins/${name}/${enable ? 'enable' : 'disable'}`)
showToast(`Plugin ${enable ? 'ativado' : 'desativado'} com sucesso`, 'ok')
await fetchPlugins()
} catch (err: any) {
showToast(err?.response?.data?.error ?? 'Erro ao alterar plugin', 'err')
} finally {
setToggling(null)
}
}
if (authLoading || user?.role !== 'admin') return null
const activeCount = plugins.filter(p => p.active).length
const categories = ['core', 'integration', 'business', 'utility']
return (
<div className="min-h-screen bg-[#080a0e] text-white p-6 lg:p-8">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-9 h-9 rounded-xl bg-brand-500/15 flex items-center justify-center">
<Plug2 size={18} className="text-brand-400" />
</div>
<h1 className="text-2xl font-black text-white">Plugins</h1>
</div>
<p className="text-slate-500 text-sm ml-12">
{activeCount} de {plugins.length} ativos
</p>
</div>
<button
onClick={fetchPlugins}
className="flex items-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white text-sm rounded-xl transition-colors"
>
<RefreshCw size={14} />
Atualizar
</button>
</div>
{/* Banner de saúde do storage — visível apenas quando há problema */}
{storageHealth && storageHealth.reason !== 'ok' && (() => {
const cfg = STORAGE_ALERT_CONFIG[storageHealth.reason]
return (
<div className={`flex items-start gap-3 px-4 py-3 rounded-xl border mb-6 ${cfg.color}`}>
<span className="text-lg font-bold shrink-0 mt-0.5">{cfg.icon}</span>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm">{cfg.label}</p>
<p className="text-xs mt-0.5 opacity-80">{storageHealth.message}</p>
</div>
<button
onClick={fetchStorageHealth}
className="shrink-0 text-xs underline opacity-60 hover:opacity-100 transition-opacity mt-0.5"
>
Verificar novamente
</button>
</div>
)
})()}
{loading ? (
<div className="flex items-center justify-center py-24">
<Loader2 size={32} className="text-brand-500 animate-spin" />
</div>
) : (
<div className="space-y-8">
{categories.map(cat => {
const catPlugins = plugins.filter(p => p.category === cat)
if (catPlugins.length === 0) return null
return (
<div key={cat}>
<h2 className="text-xs font-black text-slate-500 uppercase tracking-widest mb-3 flex items-center gap-2">
<span className={`inline-block w-2 h-2 rounded-full ${cat === 'core' ? 'bg-blue-400' : cat === 'integration' ? 'bg-purple-400' : cat === 'business' ? 'bg-green-400' : 'bg-yellow-400'}`} />
{CATEGORY_LABELS[cat]}
</h2>
<div className="space-y-3">
{catPlugins.map(plugin => (
<PluginCard
key={plugin.name}
plugin={plugin}
onToggle={handleToggle}
onConfigure={setConfigPlugin}
toggling={toggling}
/>
))}
</div>
</div>
)
})}
</div>
)}
</div>
{/* Config Modal */}
<AnimatePresence>
{configPlugin && (
configPlugin.name === 'UnifiedStorageProvider' ? (
<WasabiStorageModal
plugin={configPlugin}
onClose={() => setConfigPlugin(null)}
onSaved={() => {
showToast('Configuração salva com sucesso', 'ok')
fetchPlugins()
}}
/>
) : (
<ConfigModal
plugin={configPlugin}
onClose={() => setConfigPlugin(null)}
onSaved={() => {
showToast('Configuração salva com sucesso', 'ok')
fetchPlugins()
}}
/>
)
)}
</AnimatePresence>
{/* Toast */}
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className={`fixed bottom-6 right-6 flex items-center gap-3 px-5 py-3.5 rounded-2xl shadow-2xl text-sm font-semibold z-[100] ${
toast.type === 'ok'
? 'bg-green-500/15 border border-green-500/30 text-green-300'
: 'bg-red-500/15 border border-red-500/30 text-red-300'
}`}
>
{toast.type === 'ok' ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
{toast.msg}
</motion.div>
)}
</AnimatePresence>
</div>
)
}