Two different things live here. API integrations are server-to-server connections (texting, email, RouteOne, CARFAX, vAuto, GM, and AI page reading for old reports) —
an administrator configures them, and each one shows Connected only after a real test with the provider succeeds.
Vendor sites below simply open the vendor's own website in a tab with your own login.
{window.IntegrationStatusStrip
?
:
Integration status unavailable — ConnectedApps.jsx.txt is not loaded.
}
{window.ConnectedAppsPanel
?
:
Vendor site launchers unavailable — ConnectedApps.jsx.txt is not loaded.
}
go && go("routeone")}>⇪ RouteOne Module go && go("uploadtool")}>⇧ Import Legacy Data
{admin && go && go("settings")}>Settings ▸ Integrations}
ESC-Close
);
};
/* ─── Admin: one provider card ─────────────────────────────────────────── */
const intInp = { width: "100%", boxSizing: "border-box", padding: "6px 8px", border: "1px solid #C8CCD2", borderRadius: 4, fontSize: 12.5 };
// What a pasted key will look like after the SERVER cleans it (quotes, "Bearer ", header names, spaces and line breaks).
// Used only for the hint under the key box — the key itself is never shown, stored or logged here.
const intKeyHint = (raw) => {
const t = String(raw || "");
if (!t.trim()) return null;
const cleaned = t.replace(/^\s*(x-api-key|authorization)\s*:\s*/i, "").replace(/\s+/g, "").replace(/^["'`]+|["'`]+$/g, "").replace(/^bearer/i, "").replace(/^["'`]+|["'`]+$/g, "");
const notes = [];
if (cleaned !== t.trim()) notes.push({ bad: false, t: "Spaces, line breaks, quotes and the word \"Bearer\" are removed for you." });
if (!/^sk-ant-/.test(cleaned)) notes.push({ bad: true, t: "This does not look like an Anthropic key - they start with sk-ant-. Copy it again from console.anthropic.com > API keys." });
else if (cleaned.length < 40) notes.push({ bad: true, t: "This key looks cut off (too short). Copy the whole key again." });
return notes;
};
const IntProviderCard = ({ p, onChanged, onOpenMessaging, onShowLog, embedded, onOpenOldReports }) => {
const api = window.k2bIntApi;
const isAi = p.id === "anthropic";
const [cfg, setCfg] = React.useState(p.config || {});
const [sec, setSec] = React.useState({}); // typed secrets — never pre-filled
const [busy, setBusy] = React.useState("");
const [msg, setMsg] = React.useState(null);
const [showMap, setShowMap] = React.useState(false);
const [confirmRemove, setConfirmRemove] = React.useState(null); // secret field name awaiting "Remove stored key" confirmation
const aiv = isAi && window.useK2BAiStatus ? window.useK2BAiStatus() : null;
React.useEffect(() => { setCfg(p.config || {}); }, [p.updatedAt]);
const ftype = (f) => f.type || "text";
const same = (f, a, b) => ftype(f) === "checkbox" ? (a === true || a === "true") === (b === true || b === "true") : String(a == null ? "" : a) === String(b == null ? "" : b);
const dirty = Object.keys(sec).some(k => sec[k]) || p.fields.some(f => !f.secret && ftype(f) !== "readonly" && !same(f, cfg[f.f], (p.config || {})[f.f]));
if (window.useK2BUnsaved) window.useK2BUnsaved("integration-" + p.id, { label: "Integrations — " + p.label.split(" —")[0], isDirty: () => dirty, discard: () => { setSec({}); setCfg(p.config || {}); } }); // RC35 (brd)
const fmt = (d) => d ? new Date(d).toLocaleString([], { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" }) : "never";
const shortName = p.label.split(" —")[0];
const refreshAi = () => { if (isAi && window.K2BAiStatus) window.K2BAiStatus.refresh(); };
const changed = (np) => { onChanged && onChanged(np); refreshAi(); };
const save = async () => {
setBusy("save"); setMsg(null);
const secrets = {}; Object.keys(sec).forEach(k => { if (sec[k] && sec[k].trim()) secrets[k] = sec[k].trim(); });
const config = {}; p.fields.forEach(f => { if (!f.secret && ftype(f) !== "readonly" && f.f in cfg) config[f.f] = cfg[f.f]; });
const r = await api("/admin/" + p.id, "POST", { config, secrets });
setBusy("");
if (!r.ok) { setMsg({ bad: true, t: r.error || "Save failed" }); return; }
setSec({});
const miss = r.data.missing || [];
const np = r.data.provider || p;
const hint = np.secretHints && np.secretHints.apiKey ? " Key stored encrypted (" + np.secretHints.apiKey + ")." : "";
setMsg({ bad: false, t: miss.length ? "Saved. Still missing: " + miss.join(", ") + "." : isAi ? "Saved." + hint + " Next: press Test connection - AI reading is not usable until a test succeeds." : "Saved (secrets encrypted on the server). Run Test connection — it is not connected until a test succeeds." });
changed(np);
};
const toggle = async (on, quiet) => {
setBusy("enable");
const r = await api("/admin/" + p.id + "/enable", "POST", { enabled: on });
setBusy("");
if (!r.ok) { setMsg({ bad: true, t: r.error }); if (r.data && r.data.provider) changed(r.data.provider); return null; }
changed(r.data.provider);
return r.data.provider;
};
const test = async () => {
setBusy("test"); setMsg(null);
const r = await api("/admin/" + p.id + "/test", "POST", {});
setBusy("");
if (!r.ok) { setMsg({ bad: true, t: r.error || "Test failed" }); return; }
const d = r.data;
let text = (d.connected ? "Connected — " : "Not connected — ") + d.detail;
if (d.stale) text += " (The settings changed while the test ran - press Test connection again.)";
let np = d.provider;
// AI Import has no other job than reading pages: a passing test with consent ticked switches it on, so "Connected" really means "usable".
if (isAi && d.connected && np && !np.enabled && (np.config || {}).consent) {
const after = await toggle(true, true);
if (after) { np = after; text += " AI reading is now ON."; } else text += " It is still switched off - see the message above.";
}
setMsg({ bad: !d.connected, t: text });
window.k2bToast && window.k2bToast(shortName + (d.connected ? ": connection test passed." : ": not connected — see the card."), d.connected ? "ok" : "warn", 3600);
changed(np);
};
const removeSecret = async (field) => {
setConfirmRemove(null); setBusy("remove"); setMsg(null);
const r = await api("/admin/" + p.id + "/clear-secret", "POST", { field });
setBusy("");
if (!r.ok) { setMsg({ bad: true, t: r.error || "Could not remove the stored key" }); return; }
setSec(s => { const n = { ...s }; delete n[field]; return n; });
setMsg({ bad: false, t: "The stored key was removed and " + shortName + " was switched off. Paste a new key and press Save to use it again." });
changed(r.data.provider);
};
const lab = { fontSize: 11.5, fontWeight: 600, color: "#555" };
const helpSt = { fontSize: 11, color: "#777", lineHeight: 1.45, fontWeight: 400 };
const renderField = (f) => {
const t = ftype(f);
const help = f.help ? {f.help} : null;
if (t === "readonly") {
const v = cfg[f.f]; if (!v) return null;
return
Used today: {aiv.limits.pagesToday} of {aiv.limits.maxPagesPerDay} pages
{aiv.pricing ? · about ${aiv.pricing.estPerPageUsd.toFixed(2)} a page on {aiv.modelName || aiv.model} (${aiv.pricing.inputPerMTok} in / ${aiv.pricing.outputPerMTok} out per million tokens) : null}
)}
Last test{fmt(p.lastTestAt)}Last successful test / sync{fmt(p.lastOkAt)}{p.status === "error" || !p.lastError ? "Last error" : "Last test note"}{p.lastError || "—"}Settings changed{p.updatedAt ? fmt(p.updatedAt) + (p.updatedBy ? " by " + p.updatedBy : "") : "—"}
{p.enabled && p.status !== "connected" && (
Enabled, but nothing is sent or synced through {shortName} until a connection test succeeds.
)}
{showMap && (
Prepared data mapping (activates with credentials + provider approval)
The stored key is deleted from the server and {shortName} is switched off. Nothing that was already imported changes.
To use it again, paste a key and press Save, then Test connection.
)}
);
};
/* ─── AI Import settings (window opened from Upload & Import > Old reports; same card as Settings > Integrations) ── */
const AiImportSettingsPanel = ({ onClose }) => {
const v = window.useK2BAiStatus ? window.useK2BAiStatus() : null;
const [prov, setProv] = React.useState(null);
const [err, setErr] = React.useState("");
const [loading, setLoading] = React.useState(false);
const admin = !!(v && v.admin);
const load = React.useCallback(async () => {
setLoading(true);
const r = await window.k2bIntApi("/admin");
setLoading(false);
if (r.ok) { setProv(((r.data && r.data.providers) || []).find(x => x.id === "anthropic") || null); setErr(r.data && (r.data.providers || []).some(x => x.id === "anthropic") ? "" : "This server does not have AI Import installed yet (no \"anthropic\" provider). Run the AI Import server update."); }
else setErr(r.status === 403 ? "Administrator access is required." : (r.error || "Server unreachable"));
}, []);
React.useEffect(() => { if (admin) load(); }, [admin]);
const lim = v && v.limits;
return (
Only an administrator can change the key, the model or the consent. Ask an administrator to open Upload & Import > Old reports > AI Import settings (or Settings > Integrations (API) > AI Import).
Administrator only. Credentials are stored encrypted on the DMS server and are never sent back to any browser — a stored secret shows as stored ✓.
A provider shows Connected only after Test connection reaches the provider and succeeds. RouteOne, CARFAX, vAuto and GM require partner
onboarding/approval from the vendor before any live test is possible; until then the best status is Ready — waiting on provider.
AI Import (Claude) reads scanned old reports for Upload & Import › Old reports: paste the Anthropic key, tick the consent box, press Save, then Test connection — it turns itself on when the test passes.
The server is in mock mode (INTEGRATIONS_MOCK=1 in the server .env). The existing RouteOne / CARFAX / vAuto / e-sign routes return simulated
results; those providers test as Mock mode — not connected. Remove the flag on the server when real partner credentials are in place.