feat(newwhats): pareamento por TOKEN — cole 1 token e auto-configura

- Backend POST /api/nw/config/token (superadmin): decodifica nwpair_<base64url({u,k,s})>,
  VALIDA no motor (/api/ext/v1/sessions) e salva URL+chave+webhook. Token inválido/
  sem conexão é rejeitado antes de salvar.
- Frontend: campo 'Token de pareamento' como caminho principal (textarea + Aplicar);
  estado da conexão; clínica no dropdown (local); campos manuais em 'configuração
  manual' (avançado).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-28 19:05:13 +02:00
parent 9874eb4345
commit 13af039555
2 changed files with 99 additions and 21 deletions
+34
View File
@@ -51,6 +51,40 @@ export function registerNewwhats(app, pool, opts = {}) {
}
});
// Aplica um TOKEN de pareamento — auto-configura URL + chave + segredo do webhook.
// Token = "nwpair_" + base64url(JSON({ u: motorUrl, k: integrationKey, s: webhookSecret })).
// A clínica continua sendo escolhida localmente (o motor não conhece os IDs daqui).
app.post('/api/nw/config/token', superadminGuard, async (req, res) => {
try {
let raw = String((req.body || {}).token || '').trim();
raw = raw.replace(/^nwpair[_:]\s*/i, '');
let p;
try { p = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); }
catch { return res.status(400).json({ error: 'Token inválido (não foi possível decodificar).' }); }
const motorUrl = (p.u || p.url || '').replace(/\/$/, '');
const integrationKey = p.k || p.key || p.integrationKey || '';
const webhookSecret = p.s || p.secret || p.webhookSecret || '';
if (!motorUrl || !integrationKey) {
return res.status(400).json({ error: 'Token incompleto (faltam URL ou chave).' });
}
// Valida contra o motor ANTES de salvar.
let motorOk = false, detail = '';
try {
const r = await fetch(`${motorUrl}/api/ext/v1/sessions`, { headers: { 'x-nw-key': integrationKey } });
motorOk = r.ok; detail = r.ok ? 'Conexão OK' : `Motor respondeu ${r.status}`;
} catch (e) { detail = `Motor inacessível: ${e.message}`; }
if (!motorOk) return res.status(400).json({ error: `Token não validou no motor: ${detail}` });
const cur = getConfig();
await saveConfigToDb(pool, { motorUrl, integrationKey, webhookSecret: webhookSecret || cur.webhookSecret, clinicaId: cur.clinicaId });
res.json({ ok: true, configured: isConfigured(), motorUrl, hasWebhookSecret: Boolean(webhookSecret || cur.webhookSecret), detail });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Lista de clínicas para o dropdown.
app.get('/api/nw/clinicas', superadminGuard, async (_req, res) => {
try {
+65 -21
View File
@@ -215,25 +215,47 @@ const NewwhatsConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }>
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [test, setTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [token, setToken] = useState('');
const [applying, setApplying] = useState(false);
const [advanced, setAdvanced] = useState(false);
const loadCfg = async () => {
try {
const cfgR = await fetch(`${NW_API}/nw/config`, { headers: nwAuthHeaders() });
if (cfgR.ok) {
const c = await cfgR.json();
setMotorUrl(c.motorUrl || ''); setClinicaId(c.clinicaId || '');
setHasKey(!!c.hasIntegrationKey); setHasSecret(!!c.hasWebhookSecret);
}
} catch { /* noop */ }
};
useEffect(() => {
(async () => {
try {
const [cfgR, clR] = await Promise.all([
fetch(`${NW_API}/nw/config`, { headers: nwAuthHeaders() }),
fetch(`${NW_API}/nw/clinicas`, { headers: nwAuthHeaders() }),
]);
if (cfgR.ok) {
const c = await cfgR.json();
setMotorUrl(c.motorUrl || ''); setClinicaId(c.clinicaId || '');
setHasKey(!!c.hasIntegrationKey); setHasSecret(!!c.hasWebhookSecret);
}
const clR = await fetch(`${NW_API}/nw/clinicas`, { headers: nwAuthHeaders() });
if (clR.ok) setClinics(await clR.json());
await loadCfg();
} catch { /* noop */ }
finally { setLoading(false); }
})();
}, []);
const applyToken = async () => {
if (!token.trim()) return;
setApplying(true); setTest(null);
try {
const r = await fetch(`${NW_API}/nw/config/token`, { method: 'POST', headers: nwAuthHeaders(), body: JSON.stringify({ token: token.trim() }) });
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'Falha ao aplicar token');
await loadCfg();
setToken('');
setTest({ ok: true, msg: 'Token aplicado — ' + (j.detail || 'configurado') });
toast.success('TOKEN APLICADO! Conexão configurada.');
} catch (e: any) { setTest({ ok: false, msg: String(e.message).slice(0, 120) }); }
finally { setApplying(false); }
};
const handleSave = async () => {
setSaving(true);
try {
@@ -272,10 +294,22 @@ const NewwhatsConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }>
<div className="p-6 space-y-4">
{loading ? <div className="text-sm text-gray-500">Carregando</div> : <>
{/* Caminho principal: colar o token de pareamento → auto-configura */}
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">URL DO MOTOR</label>
<input className={inputCls} value={motorUrl} onChange={e => setMotorUrl(e.target.value)} placeholder="https://newwhats.clube67.com" />
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">TOKEN DE PAREAMENTO</label>
<textarea className={inputCls + ' font-mono text-xs h-20 resize-none'} value={token} onChange={e => setToken(e.target.value)} placeholder="Cole aqui o token do motor (nwpair_…). URL, chave e webhook são configurados automaticamente." />
<button onClick={applyToken} disabled={applying || !token.trim()} className="mt-2 w-full py-2.5 text-white rounded-xl text-xs font-black uppercase flex items-center justify-center gap-2 disabled:opacity-50" style={{ backgroundColor: plugin.color }}>
{applying ? 'APLICANDO…' : 'APLICAR TOKEN'}
</button>
</div>
{/* Estado atual da conexão */}
<div className="text-[11px] text-gray-500 bg-gray-50 rounded-xl px-3 py-2">
Motor: <b>{motorUrl || '—'}</b> · Chave: <b className={hasKey ? 'text-green-600' : ''}>{hasKey ? 'definida' : '—'}</b> · Webhook: <b className={hasSecret ? 'text-green-600' : ''}>{hasSecret ? 'definido' : '—'}</b>
</div>
{test && <div className={`text-xs font-bold ${test.ok ? 'text-green-600' : 'text-red-600'}`}>{test.ok ? '✓ ' : '✗ '}{test.msg}</div>}
{/* Clínica — local (o motor não conhece os IDs daqui) */}
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">CLÍNICA</label>
<select className={inputCls} value={clinicaId} onChange={e => setClinicaId(e.target.value)}>
@@ -283,16 +317,26 @@ const NewwhatsConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }>
{clinics.map(c => <option key={c.id} value={c.id}>{c.nome_fantasia || c.id}</option>)}
</select>
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">CHAVE DE INTEGRAÇÃO {hasKey && <span className="text-green-600">(definida)</span>}</label>
<input className={inputCls} type="password" value={integrationKey} onChange={e => setIntegrationKey(e.target.value)} placeholder={hasKey ? '•••• (deixe em branco p/ manter)' : 'nw_...'} />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">SEGREDO DO WEBHOOK {hasSecret && <span className="text-green-600">(definido)</span>}</label>
<input className={inputCls} type="password" value={webhookSecret} onChange={e => setWebhookSecret(e.target.value)} placeholder={hasSecret ? '•••• (deixe em branco p/ manter)' : '(HMAC)'} />
</div>
<button onClick={handleTest} className="text-xs font-black uppercase text-gray-600 border border-gray-200 rounded-xl px-4 py-2 hover:bg-gray-50">Testar conexão</button>
{test && <div className={`text-xs font-bold ${test.ok ? 'text-green-600' : 'text-red-600'}`}>{test.ok ? '✓ ' : '✗ '}{test.msg}</div>}
{/* Configuração manual (avançado) */}
<button onClick={() => setAdvanced(a => !a)} className="text-[10px] font-black uppercase tracking-widest text-gray-400 hover:text-gray-600">
{advanced ? '▾ ocultar configuração manual' : '▸ configuração manual'}
</button>
{advanced && <div className="space-y-4 border-t border-gray-100 pt-4">
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">URL DO MOTOR</label>
<input className={inputCls} value={motorUrl} onChange={e => setMotorUrl(e.target.value)} placeholder="https://newwhats.clube67.com" />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">CHAVE DE INTEGRAÇÃO {hasKey && <span className="text-green-600">(definida)</span>}</label>
<input className={inputCls} type="password" value={integrationKey} onChange={e => setIntegrationKey(e.target.value)} placeholder={hasKey ? '•••• (deixe em branco p/ manter)' : 'nw_...'} />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">SEGREDO DO WEBHOOK {hasSecret && <span className="text-green-600">(definido)</span>}</label>
<input className={inputCls} type="password" value={webhookSecret} onChange={e => setWebhookSecret(e.target.value)} placeholder={hasSecret ? '•••• (deixe em branco p/ manter)' : '(HMAC)'} />
</div>
<button onClick={handleTest} className="text-xs font-black uppercase text-gray-600 border border-gray-200 rounded-xl px-4 py-2 hover:bg-gray-50">Testar conexão</button>
</div>}
</>}
</div>