PageSourceSearch

https://getvisibletrust.com/app/static/views/team.js

js getvisibletrust.com collected 2026-10-02 01:30:39 UTC 28,531 bytes, 350 lines download raw bytes

1import { api, confirmDialog, fmtDate, fmtNum, h, mount, parseCsv, toast, toastError } from "../lib.js";
2import { upgradePrompt } from "../components.js";
3import { can, navigate, refreshMe, state } from "../main.js";
4
5const ROLES = ["owner", "admin", "analyst", "seller", "viewer"];
6const ROLE_HELP = {
7  owner: "Everything, including billing",
8  admin: "Everything except managing billing",
9  analyst: "Read books, lineage and export",
10  seller: "Reveal, enrich, feedback, export",
11  viewer: "Read-only; emails and phones hidden",
12};
13
14export async function teamView() {
15  const data = await api("/api/team");
16  const manage = can("team.manage");
17  const email = h("input", { class: "input", type: "email", placeholder: "[email protected]", required: true });
18  const role = h("select", { class: "select" }, ROLES.filter((r) => state.me.role === "owner" || !["owner", "admin"].includes(r))
19    .map((r) => h("option", { value: r, selected: r === "seller" ? "selected" : null }, `${r}: ${ROLE_HELP[r]}`)));
20  const inviteForm = h("form", { class: "card stack", onsubmit: async (e) => {
21    e.preventDefault();
22    try {
23      await api("/api/team/invites", { method: "POST", body: { email: email.value, role: role.value } });
24      toast(`Invite sent to ${email.value}.`);
25      navigate("/app/team", { replace: true });
26    } catch (err) { toastError(err); }
27  } }, h("h3", {}, "Invite a teammate"), h("div", { class: "grid-2" }, email, role),
28    h("div", { class: "row" }, h("span", { class: "spacer small muted" }, "Invites expire after 7 days."), h("button", { class: "btn primary" }, "Send invite")));
29
30  const members = h("div", { class: "card" }, h("table", { class: "plain" },
31    h("thead", {}, h("tr", {}, h("th", {}, "Member"), h("th", {}, "Role"), h("th", {}, "Last sign-in"), h("th", {}))),
32    h("tbody", {}, data.members.map((m) => {
33      const me = m.user_id === state.me.user.id;
34      const roleCell = manage && !me
35        ? h("select", { class: "select", onchange: async (e) => {
36            try { await api(`/api/team/members/${m.user_id}`, { method: "PATCH", body: { role: e.target.value } }); toast("Role updated."); }
37            catch (err) { toastError(err); navigate("/app/team", { replace: true }); }
38          } }, ROLES.map((r) => h("option", { value: r, selected: r === m.role ? "selected" : null }, r)))
39        : h("span", { class: "chip" }, m.role);
40      return h("tr", {},
41        h("td", {}, h("strong", {}, m.display_name || m.email), me ? " (you)" : "", h("div", { class: "small muted" }, m.email)),
42        h("td", {}, roleCell),
43        h("td", { class: "small" }, m.last_login_at ? fmtDate(m.last_login_at, true) : "Never"),
44        h("td", {}, (manage && !me) || me ? h("button", { class: "btn sm ghost danger", onclick: async () => {
45          if (!(await confirmDialog(me ? "Leave this workspace?" : `Remove ${m.email}?`, "They lose access immediately.", me ? "Leave" : "Remove", true))) return;
46          try {
47            await api(`/api/team/members/${m.user_id}`, { method: "DELETE" });
48            if (me) location.assign("/app/login"); else navigate("/app/team", { replace: true });
49          } catch (err) { toastError(err); }
50        } }, me ? "Leave" : "Remove") : null));
51    }))));
52
53  const invites = manage && data.invites.length ? h("div", { class: "card" }, h("h3", {}, "Pending invites"),
54    h("table", { class: "plain" }, h("tbody", {}, data.invites.map((i) => h("tr", {},
55      h("td", {}, i.email), h("td", {}, h("span", { class: "chip" }, i.role)), h("td", { class: "small muted" }, `expires ${fmtDate(i.expires_at)}`),
56      h("td", {}, h("button", { class: "btn sm ghost", onclick: async () => {
57        try { await api(`/api/team/invites/${i.id}`, { method: "DELETE" }); navigate("/app/team", { replace: true }); } catch (err) { toastError(err); }
58      } }, "Revoke"))))))) : null;
59
60  return h("div", { class: "stack-lg" },
61    h("div", { class: "page-head" }, h("div", {}, h("div", { class: "eyebrow" }, "Team"), h("h1", {}, state.me.tenant.name))),
62    manage ? inviteForm : null, members, invites);
63}
64
65export async function auditView() {
66  const data = await api("/api/audit");
67  return h("div", { class: "stack-lg" },
68    h("div", { class: "page-head" }, h("div", {}, h("div", { class: "eyebrow" }, "Audit log"), h("h1", {}, "Who did what"))),
69    h("div", { class: "card" }, data.entries.length ? h("table", { class: "plain" },
70      h("thead", {}, h("tr", {}, h("th", {}, "When"), h("th", {}, "Who"), h("th", {}, "Action"), h("th", {}, "Details"))),
71      h("tbody", {}, data.entries.map((e) => h("tr", {},
72        h("td", { class: "small" }, fmtDate(e.at, true)), h("td", {}, e.user_email || "system"),
73        h("td", {}, h("span", { class: "chip" }, e.action)),
74        h("td", { class: "small muted mono" }, Object.keys(e.meta || {}).length ? JSON.stringify(e.meta) : "")))))
75      : h("p", { class: "muted" }, "Nothing yet.")));
76}
77
78export async function settingsView() {
79  const me = state.me;
80  const name = h("input", { class: "input", value: me.user.display_name || "", placeholder: "Your name" });
81  const workspace = h("input", { class: "input", value: me.tenant.name });
82  return h("div", { class: "stack-lg" },
83    h("div", { class: "page-head" }, h("div", {}, h("div", { class: "eyebrow" }, "Settings"), h("h1", {}, "Profile and workspace"))),
84    h("form", { class: "card stack", onsubmit: async (e) => {
85      e.preventDefault();
86      try { await api("/api/me", { method: "PATCH", body: { display_name: name.value } }); await refreshMe(); toast("Saved."); } catch (err) { toastError(err); }
87    } }, h("h3", {}, "Your profile"), h("label", { class: "field" }, "Name", name),
88      h("p", { class: "small muted" }, `Signed in as ${me.user.email}`), h("div", {}, h("button", { class: "btn" }, "Save"))),
89    can("tenant.manage") ? h("form", { class: "card stack", onsubmit: async (e) => {
90      e.preventDefault();
91      try { await api("/api/tenant", { method: "PATCH", body: { name: workspace.value } }); location.reload(); } catch (err) { toastError(err); }
92    } }, h("h3", {}, "Workspace"), h("label", { class: "field" }, "Workspace name", workspace), h("div", {}, h("button", { class: "btn" }, "Save"))) : null,
93    can("tenant.manage") ? await apiKeysCard() : null,
94    can("campaign.read") ? await socialCard() : null,
95    can("campaign.read") ? await crmCard() : null,
96    can("tenant.manage") ? await integrationsCard() : null,
97    can("campaign.run") ? await suppressionsCard() : null,
98    h("div", { class: "card stack" }, h("h3", {}, "Your data"),
99      h("p", { class: "muted small" }, "Deleting a campaign removes your hunt: seller profile, ICP, book, scores and notes. To close the whole workspace and erase its data, email [email protected] from an owner account.")));
100}
101
102// ---------------------------------------------------------------------------
103// BYO provider keys: the email waterfall runs site → pattern → your providers, in your order.
104// ---------------------------------------------------------------------------
105const PROVIDER_HELP = {
106  hunter: "Email Finder API. Charged to your Hunter plan; we charge 1 enrich step only when it finds an address.",
107  apollo: "People Match API (work emails only, no personal emails or phones).",
108  prospeo: "Email Finder API with verification status.",
109};
110
111async function integrationsCard() {
112  const card = h("div", { class: "card stack" });
113  async function draw() {
114    const data = await api("/api/integrations");
115    mount(card,
116      h("div", { class: "card-head" }, h("h3", {}, "Email waterfall · your provider keys"), h("span", { class: "spacer" }),
117        h("span", { class: "small muted" }, "Order: ", data.waterfall.map((s, i) => [i ? " → " : "", h("span", { class: "chip mini" }, s)]))),
118      h("p", { class: "muted small" }, "When a company's site doesn't publish the contact's address and no pattern can be learned, we ask your providers in priority order and stop at the first hit. The same keys power Find people (people at an account whose titles match your ICP) when you ask for it from the lead book or a dossier. Keys are encrypted at rest and never shown again; results are private to this workspace."),
119      h("div", { class: "stack" }, data.providers.map((p) => {
120        const key = h("input", { class: "input", type: "password", placeholder: p.configured ? `Configured (${p.key_hint}) · paste a new key to replace` : "Paste API key", autocomplete: "off" });
121        const priority = h("input", { class: "input", type: "number", min: 1, max: 999, value: p.priority, "aria-label": "Priority" });
122        priority.style.width = "90px";
123        const enabled = h("input", { type: "checkbox", checked: p.configured ? p.enabled : true, "aria-label": "Enabled" });
124        const save = h("button", { class: "btn sm", onclick: async () => {
125          try {
126            await api(`/api/integrations/${p.provider}`, { method: "PUT", body: { api_key: key.value.trim() || null, enabled: enabled.checked, priority: Number(priority.value) || 100 } });
127            toast(`${p.label} saved.`); draw();
128          } catch (err) { toastError(err); }
129        } }, p.configured ? "Save" : "Add");
130        const remove = p.configured ? h("button", { class: "btn sm ghost danger", onclick: async () => {
131          if (!(await confirmDialog(`Remove ${p.label} key?`, "Existing results stay; new enrichments skip this provider.", "Remove", true))) return;
132          try { await api(`/api/integrations/${p.provider}`, { method: "DELETE" }); draw(); } catch (err) { toastError(err); }
133        } }, "Remove") : null;
134        return h("div", { class: "provider" },
135          h("div", { class: "row wrap" }, h("strong", {}, p.label), p.configured ? h("span", { class: `chip mini ${p.enabled ? "teal" : ""}` }, p.enabled ? "on" : "off") : h("span", { class: "chip mini sentinel" }, "not set"),
136            p.last_error ? h("span", { class: "chip mini danger", title: p.last_error }, "last call failed") : null,
137            p.last_used_at ? h("span", { class: "small muted" }, `used ${fmtDate(p.last_used_at, true)}`) : null),
138          h("div", { class: "small muted" }, PROVIDER_HELP[p.provider] || ""),
139          h("div", { class: "row wrap" }, h("div", { class: "spacer" }, key), h("label", { class: "row small" }, "Priority ", priority), h("label", { class: "row small" }, enabled, " enabled"), save, remove));
140      })));
141  }
142  await draw();
143  return card;
144}
145
146// ---------------------------------------------------------------------------
147// Suppression list: customers, partners, do-not-contact. Never shown in any book again.
148// ---------------------------------------------------------------------------
149async function suppressionsCard() {
150  const card = h("div", { class: "card stack" });
151  async function draw() {
152    const data = await api("/api/suppressions");
153    const input = h("textarea", { class: "textarea", rows: 3, placeholder: "Domains, one per line or comma-separated. Paste a CSV column too." });
154    const reason = h("input", { class: "input", placeholder: "Reason (optional): existing customer, partner, competitor…" });
155    const file = h("input", { type: "file", accept: ".csv,.txt", class: "hidden" });
156    file.addEventListener("change", () => {
157      const f = file.files[0]; if (!f) return;
158      const r = new FileReader();
159      r.onload = () => { input.value = parseCsv(String(r.result || "")).map((row) => row.find((v) => /\.[a-z]{2,}/i.test(v)) || "").filter(Boolean).join("\n"); };
160      r.readAsText(f);
161    });
162    const add = h("button", { class: "btn", onclick: async () => {
163      const domains = input.value.split(/[\s,;]+/).map((s) => s.trim()).filter(Boolean);
164      if (!domains.length) return;
165      try {
166        const res = await api("/api/suppressions", { method: "POST", body: { domains, reason: reason.value.trim() || null } });
167        toast(`Suppressed ${res.added} domain${res.added === 1 ? "" : "s"}${res.rejected_existing ? `; ${res.rejected_existing} existing rows marked rejected` : ""}.`);
168        draw();
169      } catch (err) { toastError(err); }
170    } }, "Suppress");
171    mount(card,
172      h("div", { class: "card-head" }, h("h3", {}, "Suppression list"), h("span", { class: "spacer" }), h("span", { class: "small muted" }, `${fmtNum(data.total)} domains`)),
173      h("p", { class: "muted small" }, "Companies you never want in a lead book: existing customers, partners, do-not-contact. Graph search, AI discovery and imports skip them; rows already in your books are marked rejected."),
174      input, h("div", { class: "row wrap" }, h("div", { class: "spacer" }, reason), h("button", { class: "btn sm ghost", onclick: () => file.click() }, "Upload CSV"), file, add),
175      data.domains.length ? h("div", { class: "chips" }, data.domains.slice(0, 60).map((d) => h("span", { class: "chip", title: d.reason || "" }, d.domain,
176        h("button", { class: "chip-x", "aria-label": `Remove ${d.domain}`, onclick: async () => {
177          try { await api(`/api/suppressions/${encodeURIComponent(d.domain)}`, { method: "DELETE" }); draw(); } catch (err) { toastError(err); }
178        } }, "×"))), data.domains.length > 60 ? h("span", { class: "small muted" }, `+${data.domains.length - 60} more`) : null) : null);
179  }
180  await draw();
181  return card;
182}
183
184// ---------------------------------------------------------------------------
185// API keys: agents (Claude, ChatGPT, Cursor…) and scripts. Shown once, stored hashed.
186// ---------------------------------------------------------------------------
187async function apiKeysCard() {
188  const card = h("div", { class: "card stack" });
189  async function draw(freshKey) {
190    const data = await api("/api/keys");
191    const name = h("input", { class: "input", placeholder: "Key name, e.g. Claude research agent", maxlength: 80 });
192    const role = h("select", { class: "select" },
193      h("option", { value: "seller" }, "seller: reveal, enrich, export"),
194      h("option", { value: "analyst" }, "analyst: read + export only"),
195      state.me.role === "owner" ? h("option", { value: "admin" }, "admin: also create campaigns, manage team") : null);
196    role.style.width = "auto";
197    const create = h("button", { class: "btn primary", onclick: async () => {
198      if (!name.value.trim()) { toast("Give the key a name.", "error"); return; }
199      try { const res = await api("/api/keys", { method: "POST", body: { name: name.value.trim(), role: role.value } }); draw(res); }
200      catch (err) { toastError(err); }
201    } }, "Create key");
202    const mcpConfig = JSON.stringify({ mcpServers: { visibletrust: { url: data.mcp_url, headers: { Authorization: "Bearer <your key>" } } } }, null, 2);
203    mount(card,
204      h("div", { class: "card-head" }, h("h3", {}, "API keys · agents and the MCP server"), h("span", { class: "spacer" }),
205        h("a", { class: "small", href: "/api/docs", target: "_blank", rel: "noopener" }, "OpenAPI docs")),
206      h("p", { class: "muted small" }, "A key acts as a member of this workspace with a fixed role: same metering, same permissions, same row-level security. Point Claude, ChatGPT, Cursor or your own agent at the MCP server and it can run the whole workflow: learn → ICP → find → reveal → enrich → dossier."),
207      freshKey ? h("div", { class: "banner teal stack" },
208        h("div", {}, h("strong", {}, `New key "${freshKey.prefix}"`), " — copy it now, it won't be shown again."),
209        h("code", { class: "keybox", tabindex: 0, onclick: (e) => { const r = document.createRange(); r.selectNodeContents(e.currentTarget); getSelection().removeAllRanges(); getSelection().addRange(r); } }, freshKey.key),
210        h("div", { class: "row wrap" }, h("button", { class: "btn sm", onclick: async () => { try { await navigator.clipboard.writeText(freshKey.key); toast("Copied."); } catch { toast("Select and copy the key manually."); } } }, "Copy key"))) : null,
211      h("div", { class: "row wrap" }, h("div", { class: "spacer" }, name), role, create),
212      data.keys.length ? h("table", { class: "plain" },
213        h("thead", {}, h("tr", {}, h("th", {}, "Name"), h("th", {}, "Key"), h("th", {}, "Role"), h("th", {}, "Last used"), h("th", {}))),
214        h("tbody", {}, data.keys.map((k) => h("tr", { class: k.revoked_at ? "muted" : "" },
215          h("td", {}, k.name), h("td", { class: "mono" }, k.prefix), h("td", {}, h("span", { class: "chip" }, k.role)),
216          h("td", { class: "small" }, k.revoked_at ? `revoked ${fmtDate(k.revoked_at)}` : k.last_used_at ? fmtDate(k.last_used_at, true) : "never"),
217          h("td", {}, k.revoked_at ? null : h("button", { class: "btn sm ghost danger", onclick: async () => {
218            if (!(await confirmDialog(`Revoke "${k.name}"?`, "Anything using it stops working immediately.", "Revoke", true))) return;
219            try { await api(`/api/keys/${k.id}`, { method: "DELETE" }); draw(); } catch (err) { toastError(err); }
220          } }, "Revoke")))))) : h("p", { class: "muted small" }, "No keys yet."),
221      h("details", { class: "small" }, h("summary", {}, "Connect an MCP client"),
222        h("p", { class: "muted" }, `MCP endpoint: ${data.mcp_url} (Streamable HTTP, bearer auth). Example client config:`),
223        h("pre", { class: "codeblock" }, mcpConfig)));
224  }
225  await draw();
226  return card;
227}
228
229// ---------------------------------------------------------------------------
230// CRM connections + Slack alerts
231// ---------------------------------------------------------------------------
232async function crmCard() {
233  const card = h("div", { class: "card stack" });
234  const manage = can("tenant.manage");
235  async function draw() {
236    const [data, slack] = await Promise.all([api("/api/crm"), api("/api/alerts/slack")]);
237    const params = new URLSearchParams(location.search);
238    const banner = params.get("crm") === "error" ? h("div", { class: "banner danger" }, "The CRM connection didn't complete. Try again.")
239      : params.get("crm") ? h("div", { class: "banner teal" }, `${params.get("crm")} connected.`) : null;
240    const rows = data.providers.map((p) => {
241      const token = p.token_paste && manage ? h("input", { class: "input", type: "password", placeholder: p.connected ? "Paste a new private-app token to replace" : "Paste a HubSpot private-app token", autocomplete: "off" }) : null;
242      const actions = [];
243      if (manage && p.token_paste) actions.push(h("button", { class: "btn sm", onclick: async () => {
244        if (!token.value.trim()) return;
245        try { const res = await api("/api/crm/hubspot/token", { method: "PUT", body: { token: token.value.trim() } }); toast(`HubSpot connected (${res.account}).`); draw(); }
246        catch (err) { toastError(err); }
247      } }, p.connected ? "Replace token" : "Connect with token"));
248      if (manage) actions.push(h("a", { class: `btn sm ${p.oauth_available ? "" : "disabled"}`, href: p.oauth_available ? `/api/crm/${p.provider}/connect` : "#",
249        title: p.oauth_available ? "" : `${p.label} sign-in isn't set up on this server yet`,
250        onclick: (e) => { if (!p.oauth_available) { e.preventDefault(); toast(`${p.label} sign-in isn't set up on this server yet.${p.token_paste ? " Paste a private-app token instead." : ""}`); } } },
251        p.connected ? "Reconnect" : `Sign in with ${p.label}`));
252      if (p.connected && can("campaign.run")) actions.push(h("button", { class: "btn sm", onclick: async () => {
253        if (!(await confirmDialog(`Suppress your ${p.label} accounts?`, "Every company in your CRM with a website is added to the suppression list, so existing customers never appear in a lead book.", "Suppress"))) return;
254        try { const res = await api(`/api/crm/${p.provider}/pull`, { method: "POST", body: { mode: "suppressions", limit: 2000 } }); toast(`Suppressed ${res.suppressed} of ${res.fetched} CRM companies.`); }
255        catch (err) { toastError(err); }
256      } }, "Suppress my customers"));
257      if (p.connected && manage) actions.push(h("button", { class: "btn sm ghost danger", onclick: async () => {
258        if (!(await confirmDialog(`Disconnect ${p.label}?`, "Pushes and pulls stop; data already in the CRM stays.", "Disconnect", true))) return;
259        try { await api(`/api/crm/${p.provider}`, { method: "DELETE" }); draw(); } catch (err) { toastError(err); }
260      } }, "Disconnect"));
261      return h("div", { class: "provider" },
262        h("div", { class: "row wrap" }, h("strong", {}, p.label),
263          p.connected ? h("span", { class: "chip mini teal" }, `connected · ${p.account || p.kind}`) : h("span", { class: "chip mini sentinel" }, "not connected"),
264          p.last_error ? h("span", { class: "chip mini danger", title: p.last_error }, "last call failed") : null,
265          p.last_used_at ? h("span", { class: "small muted" }, `used ${fmtDate(p.last_used_at, true)}`) : null),
266        h("div", { class: "row wrap" }, token ? h("div", { class: "spacer" }, token) : h("span", { class: "spacer" }), actions));
267    });
268    const hook = h("input", { class: "input", type: "password", placeholder: slack.connected ? `Connected (${slack.hint}) · paste a new webhook to replace` : "https://hooks.slack.com/services/…", autocomplete: "off" });
269    mount(card, banner,
270      h("div", { class: "card-head" }, h("h3", {}, "CRM connections")),
271      h("p", { class: "muted small" }, "Pull your CRM accounts in as candidates or as a suppression list; push revealed accounts out as company + contact + an evidence note with source links. Tokens are stored encrypted, per workspace."),
272      h("div", { class: "stack" }, rows),
273      h("div", { class: "card-head" }, h("h3", {}, "Slack alerts for watchlists")),
274      h("p", { class: "muted small" }, "Watched accounts are re-checked nightly; new signals arrive as an email digest and, with a webhook here, in Slack. Turn Slack on per campaign in its Activity tab."),
275      manage ? h("div", { class: "row wrap" }, h("div", { class: "spacer" }, hook),
276        h("button", { class: "btn sm", onclick: async () => {
277          try { await api("/api/alerts/slack", { method: "PUT", body: { webhook_url: hook.value.trim() } }); toast("Slack webhook saved."); draw(); } catch (err) { toastError(err); }
278        } }, "Save webhook"),
279        slack.connected ? h("button", { class: "btn sm ghost danger", onclick: async () => { try { await api("/api/alerts/slack", { method: "DELETE" }); draw(); } catch (err) { toastError(err); } } }, "Remove") : null)
280        : h("p", { class: "small" }, slack.connected ? "Slack webhook configured." : "No Slack webhook yet (owners and admins can add one)."));
281  }
282  await draw();
283  return card;
284}
285
286
287// ---------------------------------------------------------------------------
288// Your LinkedIn / X (through Unipile's hosted sign-in). Read-only; paid plans; daily budget shown.
289// ---------------------------------------------------------------------------
290const USAGE_LABEL = { search_page: "search pages", profile_view: "profile views", company_view: "company pages", posts_fetch: "post reads" };
291
292async function socialCard() {
293  const card = h("div", { class: "card stack" });
294  const manage = can("tenant.manage");
295  async function draw() {
296    const data = await api("/api/social");
297    const params = new URLSearchParams(location.search);
298    const banner = params.get("social") === "error" ? h("div", { class: "banner danger" }, "The sign-in didn't complete. Nothing was connected; try again.")
299      : params.get("social") === "connected" ? h("div", { class: "banner teal" }, "Connected. It can take a few seconds for the account to show as ready.") : null;
300    const gated = data.allowed <= 0;
301    const rows = data.providers.map((p) => {
302      const acc = data.accounts.find((a) => a.provider === p.provider && a.status !== "disconnected");
303      const actions = [];
304      if (manage && (!acc || acc.status === "error" || acc.status === "checkpoint")) actions.push(h("button", { class: "btn sm", disabled: gated || !data.configured, onclick: async () => {
305        try {
306          const res = await api(`/api/social/${p.provider}/connect`, { method: "POST" });
307          location.assign(res.url);
308        } catch (err) { toastError(err); }
309      } }, acc && acc.status === "checkpoint" ? "Reconnect" : `Connect ${p.label}`));
310      if (acc && acc.status !== "pending" && manage) actions.push(h("button", { class: "btn sm ghost", onclick: async () => {
311        try { const r = await api(`/api/social/${p.provider}/refresh`, { method: "POST" }); toast(`Unipile says: ${r.unipile_status}.`); draw(); } catch (err) { toastError(err); }
312      } }, "Check status"));
313      if (acc && acc.paused && manage) actions.push(h("button", { class: "btn sm primary", onclick: async () => {
314        try { await api(`/api/social/${p.provider}/resume`, { method: "POST" }); toast("Sending resumed."); draw(); } catch (err) { toastError(err); }
315      } }, "Resume sending"));
316      if (acc && manage) actions.push(h("button", { class: "btn sm ghost danger", onclick: async () => {
317        if (!(await confirmDialog(`Disconnect ${p.label}?`, "We remove the account from Unipile immediately. Facts already stored stay in your workspace.", "Disconnect", true))) return;
318        try { await api(`/api/social/${p.provider}`, { method: "DELETE" }); draw(); } catch (err) { toastError(err); }
319      } }, "Disconnect"));
320      const SEND_LABEL = { invite: "invites", dm: "DMs", email: "emails", x_dm: "X DMs" };
321      const usage = acc && acc.status === "connected" ? h("div", { class: "small muted row wrap" },
322        Object.entries(acc.usage_today || {}).filter(([, v]) => v.cap).map(([k, v]) => h("span", { class: "chip mini", title: `Today's read budget for ${USAGE_LABEL[k]}` }, `${v.used}/${v.cap} ${USAGE_LABEL[k]}`)),
323        Object.entries(acc.sends_today || {}).map(([k, v]) => h("span", { class: "chip mini source", title: `Sends today / daily cap (weekly ${v.weekly_cap}); warm-up ${Math.round((acc.warmup.factor || 1) * 100)} %` }, `${v.used}/${v.cap} ${SEND_LABEL[k] || k} sent`)),
324        acc.warmup && acc.warmup.factor < 1 ? h("span", { class: "chip mini warn", title: "Sending c
324aps ramp up over the first two weeks" }, `warm-up ${Math.round(acc.warmup.factor * 100)} %`) : null) : null;
325      const toggle = acc && acc.status === "connected" && manage ? h("label", { class: "row small", style: "gap:6px" },
326        h("input", { type: "checkbox", checked: acc.use_in_enrich, onchange: async (e) => { try { await api(`/api/social/${p.provider}`, { method: "PATCH", body: { use_in_enrich: e.target.checked } }); } catch (err) { toastError(err); } } }),
327        "Read during Enrich") : null;
328      return h("div", { class: "provider" },
329        h("div", { class: "row wrap" }, h("strong", {}, p.label),
330          acc && acc.status === "connected" ? h("span", { class: "chip mini teal" }, `connected · ${acc.display_name || acc.account_hint}${p.provider === "linkedin" ? ` · ${acc.tier.replace("_", " ")}` : ""}`)
331            : acc && acc.status === "checkpoint" ? h("span", { class: "chip mini warn" }, "needs re-verification")
332            : acc && acc.status === "pending" ? h("span", { class: "chip mini" }, "sign-in pending")
333            : h("span", { class: "chip mini sentinel" }, "not connected"),
334          acc && acc.paused ? h("span", { class: "chip mini danger", title: acc.pause_reason || "" }, "sending paused") : null,
335          acc && acc.last_error && !acc.paused ? h("span", { class: "chip mini danger", title: acc.last_error }, "attention") : null,
336          h("span", { class: "spacer" }), actions),
337        usage, toggle);
338    });
339    mount(card, banner,
340      h("div", { class: "card-head" }, h("h3", {}, "Your LinkedIn, mailbox and X"), h("span", { class: "spacer" }),
341        h("span", { class: "small muted" }, `${data.accounts.filter((a) => a.status === "connected").length}/${data.allowed} connected`)),
342      h("p", { class: "muted small" }, "Connect your own accounts. LinkedIn is read during Enrich (company page, job posts, recent posts) and Find people; LinkedIn, your mailbox and X send the outreach sequences you approve. You sign in on Unipile's page and we never see a password. Reads and sends stay inside daily and weekly limits with a two-week warm-up, and anything the platform flags pauses sending automatically."),
343      gated ? upgradePrompt("Connecting LinkedIn or X", { plan: data.plan }) : null,
344      !data.configured && !gated ? h("div", { class: "banner warn" }, "Connecting accounts isn't set up on this server yet (VT_UNIPILE_API_KEY).") : null,
345      h("div", { class: "stack" }, rows));
346  }
347  await draw();
348  return card;
349}
350

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.