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:
@@ -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.
|
// Lista de clínicas para o dropdown.
|
||||||
app.get('/api/nw/clinicas', superadminGuard, async (_req, res) => {
|
app.get('/api/nw/clinicas', superadminGuard, async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -215,25 +215,47 @@ const NewwhatsConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }>
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [test, setTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [test, setTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [advanced, setAdvanced] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadCfg = async () => {
|
||||||
(async () => {
|
|
||||||
try {
|
try {
|
||||||
const [cfgR, clR] = await Promise.all([
|
const cfgR = await fetch(`${NW_API}/nw/config`, { headers: nwAuthHeaders() });
|
||||||
fetch(`${NW_API}/nw/config`, { headers: nwAuthHeaders() }),
|
|
||||||
fetch(`${NW_API}/nw/clinicas`, { headers: nwAuthHeaders() }),
|
|
||||||
]);
|
|
||||||
if (cfgR.ok) {
|
if (cfgR.ok) {
|
||||||
const c = await cfgR.json();
|
const c = await cfgR.json();
|
||||||
setMotorUrl(c.motorUrl || ''); setClinicaId(c.clinicaId || '');
|
setMotorUrl(c.motorUrl || ''); setClinicaId(c.clinicaId || '');
|
||||||
setHasKey(!!c.hasIntegrationKey); setHasSecret(!!c.hasWebhookSecret);
|
setHasKey(!!c.hasIntegrationKey); setHasSecret(!!c.hasWebhookSecret);
|
||||||
}
|
}
|
||||||
|
} catch { /* noop */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const clR = await fetch(`${NW_API}/nw/clinicas`, { headers: nwAuthHeaders() });
|
||||||
if (clR.ok) setClinics(await clR.json());
|
if (clR.ok) setClinics(await clR.json());
|
||||||
|
await loadCfg();
|
||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
finally { setLoading(false); }
|
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 () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -272,10 +294,22 @@ const NewwhatsConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }>
|
|||||||
|
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
{loading ? <div className="text-sm text-gray-500">Carregando…</div> : <>
|
{loading ? <div className="text-sm text-gray-500">Carregando…</div> : <>
|
||||||
|
{/* Caminho principal: colar o token de pareamento → auto-configura */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">URL DO MOTOR</label>
|
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">TOKEN DE PAREAMENTO</label>
|
||||||
<input className={inputCls} value={motorUrl} onChange={e => setMotorUrl(e.target.value)} placeholder="https://newwhats.clube67.com" />
|
<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>
|
</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>
|
<div>
|
||||||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">CLÍNICA</label>
|
<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)}>
|
<select className={inputCls} value={clinicaId} onChange={e => setClinicaId(e.target.value)}>
|
||||||
@@ -283,6 +317,16 @@ const NewwhatsConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }>
|
|||||||
{clinics.map(c => <option key={c.id} value={c.id}>{c.nome_fantasia || c.id}</option>)}
|
{clinics.map(c => <option key={c.id} value={c.id}>{c.nome_fantasia || c.id}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<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>
|
<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_...'} />
|
<input className={inputCls} type="password" value={integrationKey} onChange={e => setIntegrationKey(e.target.value)} placeholder={hasKey ? '•••• (deixe em branco p/ manter)' : 'nw_...'} />
|
||||||
@@ -292,7 +336,7 @@ const NewwhatsConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }>
|
|||||||
<input className={inputCls} type="password" value={webhookSecret} onChange={e => setWebhookSecret(e.target.value)} placeholder={hasSecret ? '•••• (deixe em branco p/ manter)' : '(HMAC)'} />
|
<input className={inputCls} type="password" value={webhookSecret} onChange={e => setWebhookSecret(e.target.value)} placeholder={hasSecret ? '•••• (deixe em branco p/ manter)' : '(HMAC)'} />
|
||||||
</div>
|
</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>
|
<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>}
|
</div>}
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user