No one yet. Invite the first person above.
`; return; } // People are shown only as a count — their names are end-to-end encrypted, so // neither the server nor this console ever sees them. To act on a specific // person (e.g. remove them), do it from the app, where names are visible. Bots // are admin-authored, so they stay named and editable below. const joinedCount = users.filter((u) => !u.is_agent && u.devices > 0).length; const summary = document.createElement("p"); summary.className = "sub"; summary.textContent = `${joinedCount} ${joinedCount === 1 ? "member" : "members"}`; box.appendChild(summary); for (const u of users) { if (!u.is_agent && u.devices > 0) continue; // joined people are counted, not listed const row = document.createElement("div"); row.className = "invite"; const meta = document.createElement("div"); meta.className = "meta"; const label = document.createElement("div"); label.className = "label"; label.textContent = u.is_agent ? u.label || "(bot)" : "Pending invite"; if (u.is_agent) { const tag = document.createElement("span"); // AI = LLM-backed (what it sees goes to a model provider); BOT = deterministic. tag.textContent = u.kind === "ai" ? "AI" : "BOT"; tag.className = "bot-tag"; label.appendChild(tag); } const joined = u.devices > 0; const date = (t) => new Date(t * 1000).toLocaleDateString(); const status = document.createElement("div"); status.className = "status"; if (joined) { const when = date(u.joined_at || u.created_at); status.textContent = u.devices > 1 ? `joined ${when} · ${u.devices} devices` : `joined ${when}`; } else if (u.pending) { status.textContent = `invited ${date(u.created_at)} · not joined yet`; } else { status.textContent = `invited ${date(u.created_at)} · link expired`; } meta.appendChild(label); meta.appendChild(status); row.appendChild(meta); if (joined) { // The admin CAN'T mint a working add-device link for someone already joined — // only their existing device holds the private key needed to read their threads. // A fresh admin link would make the new device generate a different keypair and // orphan every thread wrapped to the old one. So point them at the in-app flow. const note = document.createElement("div"); note.className = "device-note"; note.textContent = "To add a device, use “Add a device” in the app on a device they're already on."; row.appendChild(note); } else if (!u.pending_dm) { // A bare (admin) pending invitee: a fresh single-use link is safe — they // generate their keypair on join. (Invitees waiting on a member's in-app DM // invite have pending_dm=true; that link is re-sent from inside the chat by the // inviter, since a bare admin link would drop them into a keyless, dead DM.) const reissue = document.createElement("button"); reissue.className = "secondary"; reissue.textContent = "Resend link"; reissue.onclick = async () => { const minted = await mintInvite("", u.id); if (minted) showInviteModal(minted.link, "Invite"); }; row.appendChild(reissue); } // Bots are editable after creation: persona, model, docs + catalog entry. if (u.is_agent) { const edit = document.createElement("button"); edit.className = "secondary"; edit.textContent = "Edit bot"; edit.onclick = () => showEditBotModal(u); row.appendChild(edit); } // Remove a person from the instance (roster management). const remove = document.createElement("button"); remove.className = "linkish danger"; remove.textContent = "Remove"; remove.onclick = async () => { if (!confirm(u.is_agent ? `Remove ${u.label || "this bot"}? This can't be undone.` : "Cancel this pending invite? This can't be undone.")) return; const res = await api("POST", `/api/admin/users/${encodeURIComponent(u.id)}/remove`); toast(res.ok ? "Removed" : "Couldn't remove"); if (res.ok) loadUsers(); }; row.appendChild(remove); box.appendChild(row); } } // Edit an existing bot's config in place. The sidecar re-pulls config on its // next reconnect (~a minute), so persona/model/docs edits apply without a // redeploy; catalog changes (description / Helpers visibility) are immediate. async function showEditBotModal(u) { const res = await api("GET", `/api/admin/bots/${encodeURIComponent(u.id)}/config`); if (!res.ok) { toast("Couldn't load the bot's config"); return; } const cfg = await res.json(); const backdrop = document.createElement("div"); backdrop.className = "qr-screen"; // same full-screen takeover as the QR modal const card = document.createElement("div"); card.className = "card qr-modal"; const h = document.createElement("h2"); h.textContent = `Edit bot: ${u.label}`; card.appendChild(h); const field = (el, value) => { el.style.cssText = "display:block;width:100%;margin:0 0 8px"; el.value = value || ""; card.appendChild(el); return el; }; const model = field(document.createElement("select"), ""); for (const [v, t] of [ ["", "Default model (Sonnet)"], ["claude-opus-4-8", "Opus — most capable"], ["claude-sonnet-5", "Sonnet — balanced"], ["claude-haiku-4-5", "Haiku — fast & cheap"], ]) { const o = document.createElement("option"); o.value = v; o.textContent = t; model.appendChild(o); } model.value = cfg.model || ""; const desc = field(document.createElement("input"), cfg.description); desc.placeholder = "What this helper does, in one line — members see this in the Helpers list"; desc.maxLength = 200; const prompt = field(document.createElement("textarea"), cfg.system_prompt); prompt.rows = 4; prompt.placeholder = "Personality / instructions (system prompt). Leave blank for the bot's default."; const docs = field(document.createElement("textarea"), cfg.docs); docs.rows = 3; docs.placeholder = "Reference the bot should always know. Optional."; const welcome = field(document.createElement("textarea"), cfg.welcome); welcome.rows = 3; welcome.placeholder = "Welcome DM sent to new members (the guide sends this). Use {name} for their name. Blank = default."; const listen = field(document.createElement("select"), ""); for (const [v, t] of [ ["", "Replies in groups only when @mentioned (default)"], ["name", "Also replies when spoken to by name (“Claude, …”)"], ]) { const o = document.createElement("option"); o.value = v; o.textContent = t; listen.appendChild(o); } // 'mention' and '' are the same mode; the form only mints '' and 'name'. listen.value = cfg.listen === "name" ? "name" : ""; const availRow = document.createElement("label"); availRow.className = "toggle-row"; availRow.style.margin = "0 0 8px"; const avail = document.createElement("input"); avail.type = "checkbox"; avail.checked = !!cfg.available_to_members; const availText = document.createElement("span"); availText.textContent = "Show in everyone's Helpers list"; availRow.appendChild(avail); availRow.appendChild(availText); card.appendChild(availRow); const rowEl = document.createElement("div"); rowEl.className = "row"; const cancel = document.createElement("button"); cancel.className = "secondary"; cancel.textContent = "Cancel"; const save = document.createElement("button"); save.textContent = "Save"; rowEl.appendChild(cancel); rowEl.appendChild(save); card.appendChild(rowEl); const close = () => { backdrop.remove(); document.removeEventListener("keydown", onKey); }; const onKey = (e) => { if (e.key === "Escape") close(); }; document.addEventListener("keydown", onKey); cancel.onclick = close; save.onclick = async () => { save.disabled = true; const r = await api("POST", `/api/admin/bots/${encodeURIComponent(u.id)}/config`, { model: model.value, system_prompt: prompt.value.trim(), docs: docs.value.trim(), listen: listen.value, description: desc.value.trim(), welcome: welcome.value.trim(), available_to_members: avail.checked, }); if (r.ok) { toast("Saved — the bot picks it up within a minute"); close(); } else { toast("Couldn't save"); save.disabled = false; } }; backdrop.appendChild(card); document.body.appendChild(backdrop); } // Show a scannable QR of an invite link, for joining in person. The QR encodes // the FULL join link — including the #k= key fragment — so it's rendered here in // the browser (never server-side, which would leak the key) and must be treated // like the secret link: anyone who scans it can read the whole chat. function showInviteModal(link, title) { // Full-screen, opaque takeover — hides the rest of the admin console so only // the QR is on screen (easier to scan, nothing sensitive shown behind it). const backdrop = document.createElement("div"); backdrop.className = "qr-screen"; const card = document.createElement("div"); card.className = "card qr-modal"; const h = document.createElement("h2"); h.textContent = title ? `Invite: ${title}` : "Invite"; card.appendChild(h); const note = document.createElement("p"); note.className = "sub"; note.textContent = "Scan to join. Anyone who scans it can read the whole chat, so treat it like the secret link."; card.appendChild(note); const wrap = document.createElement("div"); wrap.className = "qr-wrap"; try { wrap.appendChild(qrCanvas(link, { scale: 6, margin: 4 })); } catch { wrap.textContent = "This link is too long to show as a QR code."; } card.appendChild(wrap); // Show the full link too, so it can be selected and copied by hand if the // clipboard button is blocked. Tap to select all. It IS the secret — same // caveat as the QR: anyone with it can read the whole chat. const linkBox = document.createElement("div"); linkBox.className = "key-box mono"; linkBox.textContent = link; linkBox.onclick = () => { const range = document.createRange(); range.selectNodeContents(linkBox); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); }; card.appendChild(linkBox); const close = () => { backdrop.remove(); document.removeEventListener("keydown", onKey); }; const onKey = (e) => { if (e.key === "Escape") close(); }; const row = document.createElement("div"); row.className = "row"; const copyBtn = document.createElement("button"); copyBtn.className = "secondary"; copyBtn.textContent = "Copy link"; // In-modal feedback: a toast would render behind this full-screen overlay. copyBtn.onclick = async () => { try { await navigator.clipboard.writeText(link); copyBtn.textContent = "Link copied ✓"; } catch { copyBtn.textContent = "Select the link above to copy"; } setTimeout(() => (copyBtn.textContent = "Copy link"), 2000); }; const doneBtn = document.createElement("button"); doneBtn.textContent = "Done"; doneBtn.onclick = close; row.appendChild(copyBtn); row.appendChild(doneBtn); card.appendChild(row); backdrop.appendChild(card); backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); }); document.addEventListener("keydown", onKey); document.body.appendChild(backdrop); } // ---- helpers ------------------------------------------------------------ function api(method, path, body) { return fetch(path, { method, headers: { Authorization: "Bearer " + adminToken, ...(body ? { "Content-Type": "application/json" } : {}), }, body: body ? JSON.stringify(body) : undefined, }); } function show(el) { el.classList.remove("hidden"); } function hide(el) { el.classList.add("hidden"); } async function copy(text, msg) { try { await navigator.clipboard.writeText(text); toast(msg); } catch { // Clipboard blocked (e.g. lost the user gesture) — no blocking dialog; // the link is in the list with its own Copy button. toast("Created — tap “Copy link” to copy it"); } } let toastTimer; function toast(msg) { const t = document.getElementById("toast"); t.textContent = msg; t.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), 1800); }