import { randomSecret, deriveInvite } from "/static/crypto.js"; import { buildInviteFragment } from "/static/invite.js"; import { qrCanvas } from "/static/qrcode.js"; import { formatBytes } from "/static/format.js"; const ADMIN_KEY = "lchat_admin_token"; // The admin token authorises roster management only — it is NOT a decryption key. // There's no instance-wide chat key (each thread has its own); see // docs/threads-and-dms.md. The token rides in the #fragment so it isn't sent on load. const hash = new URLSearchParams(location.hash.slice(1)); const fragToken = hash.get("token"); if (fragToken) { localStorage.setItem(ADMIN_KEY, fragToken); history.replaceState(null, "", location.pathname); // scrub URL bar } const adminToken = localStorage.getItem(ADMIN_KEY); const locked = document.getElementById("locked"); const console_ = document.getElementById("console"); init(); async function init() { if (!adminToken) return show(locked); // Verify the token by hitting an admin-only endpoint. const res = await api("GET", "/api/admin/users"); if (!res.ok) { localStorage.removeItem(ADMIN_KEY); return show(locked); } hide(locked); show(console_); document.getElementById("masterLink").textContent = `${location.origin}/admin#token=${adminToken}`; document.getElementById("copyMaster").onclick = () => copy(`${location.origin}/admin#token=${adminToken}`, "Admin link copied"); wireGroupName(); wireServerAvatar(); wireUserInvites(); wireFeatures(); wireUpdates(); wireCustomApps(); wireTestPush(); wireResetInstanceKey(); wireCreateAdmin(); wireCreate(); wireCreateAgent(); renderUsers((await res.json()).users || []); } // ---- who can invite ----------------------------------------------------- async function wireUserInvites() { const box = document.getElementById("userInvites"); try { const { user_invites } = await (await api("GET", "/api/admin/settings")).json(); box.checked = !!user_invites; } catch {} box.onchange = async () => { const res = await api("POST", "/api/admin/settings", { user_invites: box.checked }); if (!res.ok) { box.checked = !box.checked; // revert on failure return toast("Couldn't save that"); } toast(box.checked ? "Members can now invite people" : "Only you can invite now"); }; } // ---- feature gates (AI / bots / apps) ------------------------------------ // Each toggle is one partial settings write (like userInvites); the server // enforces the gates — the console just flips them. Bots off also hides the // "Add an agent" section: minting an agent invite would 403 anyway. async function wireFeatures() { const rows = [ ["aiEnabled", "ai_enabled", "AI replies are on", "AI replies are off — helpers answer commands only"], ["botsEnabled", "bots_enabled", "Members can create bots again", "Bot creation is off — existing bots keep running"], ["appsEnabled", "apps_enabled", "Apps are back in the New-chat menu", "Apps are off — existing app chats keep working"], ["memberAppsEnabled", "member_apps_enabled", "Members can make their own apps again", "Member-made apps are off — apps already deployed keep working"], ]; const agentSection = document.getElementById("agentSection"); const syncAgentSection = (botsOn) => agentSection.classList.toggle("hidden", !botsOn); let settings = {}; try { settings = await (await api("GET", "/api/admin/settings")).json(); } catch {} for (const [domId, key, onMsg, offMsg] of rows) { const box = document.getElementById(domId); box.checked = settings[key] !== false; // default on (older servers omit it) if (key === "bots_enabled") syncAgentSection(box.checked); box.onchange = async () => { const res = await api("POST", "/api/admin/settings", { [key]: box.checked }); if (!res.ok) { box.checked = !box.checked; // revert on failure return toast("Couldn't save that"); } if (key === "bots_enabled") syncAgentSection(box.checked); toast(box.checked ? onMsg : offMsg); }; } } // ---- updates (auto-update settings + what the channel offers) ----------- // Feedback is inline (the status + feedback lines occupy layout space) — no // floating toast, per the convention in CLAUDE.md. async function wireUpdates() { const auto = document.getElementById("updateAuto"); const dl = document.getElementById("updateDownload"); const chan = document.getElementById("updateChannel"); const major = document.getElementById("updateMajor"); const status = document.getElementById("updateStatus"); const feedback = document.getElementById("updateFeedback"); const flash = (msg, isError = false) => { feedback.textContent = msg; feedback.style.color = isError ? "#c0392b" : ""; setTimeout(() => { if (feedback.textContent === msg) feedback.textContent = ""; }, 4000); }; const renderStatus = (s) => { const u = s.update || {}; let line = `Running build ${u.build || "unknown"}`; if (s.update_pin) { line += ` — pinned to ${s.update_pin}; automatic updates are paused (pick a channel to resume)`; } else if (u.available_version && u.available_version !== u.build) { const label = u.available_semver ? `v${u.available_semver} (${u.available_version})` : u.available_version; line += ` — ${label} is available on ${s.update_channel}`; if (u.major_held) { line += ` (held: it crosses the major version this space stays on — ${s.update_major || "?"})`; } else if (u.bake_hours) { // The supply-chain safety hold: new releases wait before auto-installing. line += ` (auto-installs after a ${u.bake_hours}-hour safety hold)`; } } else if (u.checked_at) { line += ` — up to date on ${s.update_channel}`; } else { line += " — hasn't checked for updates yet"; } status.textContent = line + "."; }; // A pin shows as an extra selected option in the channel picker; choosing a // real channel clears it (one combined write) and the option disappears. const PINNED = "__pinned__"; let pinnedOpt = null; const showPinned = (pin) => { if (pinnedOpt) { pinnedOpt.remove(); pinnedOpt = null; } if (pin) { pinnedOpt = document.createElement("option"); pinnedOpt.value = PINNED; pinnedOpt.textContent = `Pinned to ${pin}`; chan.appendChild(pinnedOpt); chan.value = PINNED; } }; let prevChannel = "stable"; let prevMajor = ""; try { const s = await (await api("GET", "/api/admin/settings")).json(); auto.checked = !!s.update_auto; dl.checked = !!s.update_download; chan.value = prevChannel = s.update_channel || "stable"; major.value = prevMajor = s.update_major || ""; showPinned(s.update_pin); renderStatus(s); } catch {} const save = async (body, revert) => { const res = await api("POST", "/api/admin/settings", body); if (!res.ok) { revert(); flash("Couldn't save that", true); return false; } // Re-read so the status line reflects the (possibly new) channel at once. try { renderStatus(await (await api("GET", "/api/admin/settings")).json()); } catch {} flash("Saved"); return true; }; auto.onchange = () => save({ update_auto: auto.checked }, () => (auto.checked = !auto.checked)); dl.onchange = () => save({ update_download: dl.checked }, () => (dl.checked = !dl.checked)); chan.onchange = async () => { if (chan.value === PINNED) return; // pinning happens via deploy tooling, not here const revert = () => (chan.value = pinnedOpt ? PINNED : prevChannel); // Picking a channel also clears any pin — that's how updates resume. if (await save({ update_channel: chan.value, update_pin: "" }, revert)) { prevChannel = chan.value; showPinned(""); } }; major.onchange = async () => { const v = major.value.trim(); if (v !== "" && !/^\d{1,4}$/.test(v)) { flash("Major version must be a number — or empty to follow everything.", true); major.value = prevMajor; return; } if (await save({ update_major: v }, () => (major.value = prevMajor))) { prevMajor = major.value = v; } }; } // ---- this space (name + optional administrator identity) ---------------- async function wireGroupName() { const input = document.getElementById("groupName"); const desc = document.getElementById("groupDesc"); const adminName = document.getElementById("adminName"); const adminEmail = document.getElementById("adminEmail"); try { const { name } = await (await fetch("/api/group")).json(); if (name) input.value = name; // The description + admin identity are member-gated on /api/group, so read // them back via the admin-authed settings endpoint instead. const settings = await (await api("GET", "/api/admin/settings")).json(); if (settings.description) desc.value = settings.description; if (settings.admin_name) adminName.value = settings.admin_name; if (settings.admin_email) adminEmail.value = settings.admin_email; } catch {} document.getElementById("nameForm").onsubmit = async (e) => { e.preventDefault(); const name = input.value.trim(); if (!name) return; const res = await api("POST", "/api/admin/group", { name, admin_name: adminName.value.trim(), admin_email: adminEmail.value.trim(), }); // The description has its own endpoint (it's also editable in-app from the // About-this-space sheet); save it with the same button. const dres = await api("POST", "/api/group-description", { description: desc.value.trim() }); toast(res.ok && dres.ok ? "Saved" : "Couldn't save"); }; } // ---- server photo ------------------------------------------------------- // The instance branding photo (public, unencrypted — like the group name). The // admin picks an image; we downscale it in-browser (small + EXIF-stripped) and // POST the raw bytes to /api/admin/server-avatar. Feedback is inline (no toast). function wireServerAvatar() { const preview = document.getElementById("serverAvatarPreview"); const pick = document.getElementById("serverAvatarPick"); const remove = document.getElementById("serverAvatarRemove"); const file = document.getElementById("serverAvatarFile"); const status = document.getElementById("serverAvatarStatus"); const setStatus = (msg, isError = false) => { status.textContent = msg || ""; status.style.color = isError ? "#c0392b" : ""; }; // Reflect current state: show the photo (cache-busted) + Remove when one is set. async function refresh() { let has = false; try { has = !!(await (await fetch("/api/group")).json()).avatar; } catch {} if (has) { preview.src = "/api/server-avatar?t=" + Date.now(); preview.classList.remove("hidden"); remove.classList.remove("hidden"); pick.textContent = "Change photo"; } else { preview.removeAttribute("src"); preview.classList.add("hidden"); remove.classList.add("hidden"); pick.textContent = "Upload photo"; } } refresh(); pick.onclick = () => file.click(); file.onchange = async () => { const f = file.files[0]; file.value = ""; if (!f) return; setStatus("Uploading…"); try { const bytes = await downscaleImage(f, 400); const res = await fetch("/api/admin/server-avatar", { method: "POST", headers: { Authorization: "Bearer " + adminToken }, body: bytes, }); if (!res.ok) throw new Error("upload"); setStatus("Server photo saved."); await refresh(); } catch { setStatus("Couldn't save that photo. Try a different image.", true); } }; remove.onclick = async () => { setStatus("Removing…"); const res = await fetch("/api/admin/server-avatar", { method: "DELETE", headers: { Authorization: "Bearer " + adminToken }, }); if (res.ok) { setStatus("Server photo removed."); await refresh(); } else { setStatus("Couldn't remove the photo.", true); } }; } // Downscale + re-encode an image to a small JPEG (caps the long edge, strips // EXIF/GPS) — mirrors shell.js processImage, kept local so the admin console needs // no shared module. Returns the encoded bytes. async function downscaleImage(file, maxDim) { const bitmap = await createImageBitmap(file); const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height)); const w = Math.round(bitmap.width * scale); const h = Math.round(bitmap.height * scale); const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; canvas.getContext("2d").drawImage(bitmap, 0, 0, w, h); const blob = await new Promise((res) => canvas.toBlob(res, "image/jpeg", 0.85)); return await blob.arrayBuffer(); } // ---- custom apps --------------------------------------------------------- // Install / remove custom mini-apps (customapps.go). The DB copy this console // manages shadows any same-named LCHAT_APPS_DIR file — the console is // authoritative; the disk dir is a dev tool. Feedback is inline (appsStatus), // never a floating toast, and every server-provided string renders via // textContent — app names and metadata are never innerHTML'd. function wireCustomApps() { const list = document.getElementById("appsList"); const pick = document.getElementById("appPick"); const file = document.getElementById("appFile"); const nameInput = document.getElementById("appName"); const source = document.getElementById("appSource"); const installBtn = document.getElementById("appInstall"); const status = document.getElementById("appsStatus"); const setStatus = (msg, isError = false) => { status.textContent = msg || ""; status.style.color = isError ? "#c0392b" : ""; }; // Mirrors the server's rule (customAppName in customapps.go) so a bad name // gets a helpful message instead of a bare 400. const NAME_RE = /^[a-z0-9-]+$/; async function refresh() { let apps = []; try { const res = await api("GET", "/api/admin/apps"); if (res.ok) apps = (await res.json()).apps || []; } catch {} list.replaceChildren(); if (!apps.length) { const p = document.createElement("p"); p.className = "sub"; p.textContent = "No custom apps installed."; list.appendChild(p); return; } // Same row shape as the People list (.invite/.meta/.label/.status), so the // two lists read as one console. for (const a of apps) { const row = document.createElement("div"); row.className = "invite"; const meta = document.createElement("div"); meta.className = "meta"; const label = document.createElement("div"); label.className = "label mono"; label.textContent = a.name; if (a.on_disk) { // Sideloaded from LCHAT_APPS_DIR — the console can't manage the file, // so the row is read-only (no Delete) with a provenance tag. const tag = document.createElement("span"); tag.textContent = "ON DISK"; tag.className = "bot-tag"; label.appendChild(tag); } const status = document.createElement("div"); status.className = "status"; const updated = `${formatBytes(a.size)} · updated ${new Date(a.updated_at * 1000).toLocaleDateString()}`; status.textContent = a.on_disk ? updated : `v${a.version} · ${updated}`; meta.append(label, status); // The module route serves the stored source as text/javascript, so a plain // link is a source viewer. const view = document.createElement("a"); view.className = "linkish"; view.textContent = "View source"; view.href = `/static/apps-custom/${encodeURIComponent(a.name)}.js`; view.target = "_blank"; view.rel = "noopener"; row.append(meta, view); if (!a.on_disk) { const del = document.createElement("button"); del.type = "button"; del.className = "linkish danger"; del.textContent = "Delete"; del.onclick = async () => { if (!confirm(`Delete the app “${a.name}” for everyone?`)) return; const res = await api("DELETE", `/api/admin/apps/${encodeURIComponent(a.name)}`); setStatus(res.ok ? `Deleted ${a.name}.` : "Couldn't delete that app.", !res.ok); refresh(); }; row.append(del); } list.appendChild(row); } } async function install(name, src) { if (!NAME_RE.test(name)) { return setStatus("App names are lowercase letters, digits, and dashes only.", true); } if (!src.trim()) return setStatus("The app source is empty.", true); setStatus("Installing…"); const res = await api("PUT", `/api/admin/apps/${encodeURIComponent(name)}`, { source: src }); if (!res.ok) return setStatus("Couldn't install that app.", true); setStatus(`Installed ${name}. Members' open apps refresh to pick it up.`); nameInput.value = ""; source.value = ""; refresh(); } pick.onclick = () => file.click(); file.onchange = () => { const f = file.files[0]; file.value = ""; if (!f) return; // The app name comes from the file name (kanban.js → kanban); install() // rejects anything that doesn't survive as a valid name. const name = f.name.replace(/\.js$/i, "").toLowerCase(); const reader = new FileReader(); reader.onload = () => install(name, String(reader.result || "")); reader.onerror = () => setStatus("Couldn't read that file.", true); reader.readAsText(f); }; installBtn.onclick = () => install(nameInput.value.trim(), source.value); refresh(); } // ---- notifications ------------------------------------------------------ // The #785 break-glass: reset an orphaned shared (instance) key. Guarded by a // typed confirmation — this is the one admin action that permanently orphans // content (old-key photos/abouts/member apps), so a misclick must not fire it. function wireResetInstanceKey() { const status = document.getElementById("resetKeyStatus"); document.getElementById("resetInstanceKey").onclick = async () => { const word = prompt( "Reset this space's shared key?\n\n" + "Profile photos, about-me notes and member-built apps locked with the old key are lost for good. " + "Names return automatically; photos and apps must be re-added. Open groups close to invite-only. " + "Messages are untouched.\n\nType RESET to confirm.", ); if (word === null) return; if (word.trim().toUpperCase() !== "RESET") { status.textContent = "Not reset — confirmation didn't match."; return; } const res = await api("POST", "/api/admin/instance-key/reset"); status.textContent = res.ok ? "Shared key reset. The next member to open the app mints a fresh one." : "Couldn't reset — try again."; }; } function wireTestPush() { document.getElementById("testPush").onclick = async () => { const res = await api("POST", "/api/admin/test-push"); if (!res.ok) return toast("Couldn't send test"); const { sent } = await res.json(); toast(`Test sent to ${sent} device(s)`); }; } // ---- invites & people --------------------------------------------------- function wireCreateAdmin() { document.getElementById("createAdminForm").onsubmit = async (e) => { e.preventDefault(); const label = document.getElementById("adminLabel").value.trim(); // Show confirmation before minting the admin invite showAdminConfirmation(label); }; } function showAdminConfirmation(label) { const backdrop = document.createElement("div"); backdrop.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999"; const card = document.createElement("div"); card.style.cssText = "background:var(--panel);border-radius:12px;padding:28px;max-width:380px;box-shadow:0 10px 40px rgba(0,0,0,0.3)"; const title = document.createElement("h3"); title.textContent = "Create admin invite"; title.style.cssText = "margin:0 0 16px;font-size:18px;font-weight:700"; card.appendChild(title); const warning = document.createElement("div"); warning.style.cssText = "background:color-mix(in srgb,#c0392b 10%,transparent);border-left:4px solid #c0392b;padding:12px;border-radius:4px;margin:0 0 16px;font-size:13px;line-height:1.5;color:var(--ink)"; warning.innerHTML = "⚠️ This is powerful
Anyone who claims this link becomes an admin with full control over this server."; card.appendChild(warning); const guidance = document.createElement("p"); guidance.style.cssText = "margin:0 0 16px;font-size:13px;color:var(--muted);line-height:1.6"; guidance.innerHTML = "Use this to:
• Delegate admin duties to someone you trust
• Recover access if you lose your token

Treat this link like a password — keep it private."; card.appendChild(guidance); const row = document.createElement("div"); row.style.cssText = "display:flex;gap:8px;margin-top:16px"; const cancelBtn = document.createElement("button"); cancelBtn.className = "secondary"; cancelBtn.textContent = "Cancel"; cancelBtn.onclick = () => backdrop.remove(); const confirmBtn = document.createElement("button"); confirmBtn.textContent = "Create invite"; confirmBtn.onclick = async () => { backdrop.remove(); toast("Creating invite…"); const minted = await mintInvite(label, null, false, null, true); if (!minted) return; document.getElementById("adminLabel").value = ""; showInviteModal(minted.link, label ? `Admin invite for ${label}` : "Admin invite", true); loadUsers(); }; row.append(cancelBtn, confirmBtn); card.appendChild(row); backdrop.appendChild(card); backdrop.addEventListener("click", (e) => { if (e.target === backdrop) backdrop.remove(); }); document.body.appendChild(backdrop); } function wireCreate() { document.getElementById("createForm").onsubmit = async (e) => { e.preventDefault(); const label = document.getElementById("label").value.trim(); const tierSel = document.getElementById("inviteTier"); const minted = await mintInvite(label, null, false, null, false, (tierSel && tierSel.value) || ""); if (!minted) return; document.getElementById("label").value = ""; showInviteModal(minted.link, label || "New invite"); loadUsers(); }; loadInviteTiers(); } // Cohort invites: when the space has access levels, the invite form offers // "add them to " — the claimer lands in that level on join (their // level key follows from a holder once they publish a pubkey). async function loadInviteTiers() { const sel = document.getElementById("inviteTier"); if (!sel) return; let tiers = []; try { tiers = (await (await api("GET", "/api/admin/tiers")).json()).tiers || []; } catch { return; // older server or offline — the picker just stays hidden } if (!tiers.length) return; sel.textContent = ""; const none = document.createElement("option"); none.value = ""; none.textContent = "No access level"; sel.appendChild(none); for (const t of tiers) { if (t.key_lost) continue; // a dead level can't receive anyone const o = document.createElement("option"); o.value = t.id; o.textContent = `Add them to ${t.name}`; sel.appendChild(o); } sel.classList.remove("hidden"); } // Adding an agent is a deliberate, separate action from inviting a person: it // mints an invite flagged as an agent, so the joiner is badged as a bot. The // sidecar consumes this link to provision the bot's identity. function wireCreateAgent() { document.getElementById("agentForm").onsubmit = async (e) => { e.preventDefault(); const name = document.getElementById("agentLabel").value.trim(); const config = { model: document.getElementById("agentModel").value, system_prompt: document.getElementById("agentPrompt").value.trim(), docs: document.getElementById("agentDocs").value.trim(), listen: document.getElementById("agentListen").value, description: document.getElementById("agentDescription").value.trim(), available_to_members: document.getElementById("agentAvailable").checked, }; const minted = await mintInvite(name, null, true, config); if (!minted) return; document.getElementById("agentLabel").value = ""; document.getElementById("agentDescription").value = ""; document.getElementById("agentModel").value = ""; document.getElementById("agentPrompt").value = ""; document.getElementById("agentDocs").value = ""; document.getElementById("agentListen").value = ""; document.getElementById("agentAvailable").checked = true; showInviteModal(minted.link, name || "New agent"); loadUsers(); }; } // Mint a single-use invite: generate the secret T client-side, wrap the group // key under it, and store only {id, wrapped} server-side. Pass a userId to // re-issue for an existing person (add/recover a device). Returns the link. async function mintInvite(label, userId, agent, config, grantsAdmin, tierID) { // Auth-only invite (threads model): no instance-wide key to wrap — the joiner // generates their own user keypair on join. See docs/threads-and-dms.md. const T = randomSecret(); const { id, claimHash } = await deriveInvite(T); const res = await api("POST", "/api/admin/key-invites", { // Only agents carry a server-side plaintext name (admin-authored, and shown // as a bot badge). A person's suggested name is NEVER sent to the server — // it rides in the link #fragment below and the joiner publishes it encrypted. label: agent ? label : "", user_id: userId || "", id, wrapped: "", claim_hash: claimHash, agent: !!agent, grants_admin: !!grantsAdmin, tier_id: tierID || "", ...(config || {}), // model, system_prompt, docs — agent only }); if (!res.ok) { toast("Couldn't create invite"); return null; } // Neutral slug (the path reaches the server); a person's suggested name rides // in the #fragment, which browsers never send to the server. const frag = buildInviteFragment(T, agent ? "" : label); return { link: `${location.origin}/join/join#${frag}` }; } async function loadUsers() { const res = await api("GET", "/api/admin/users"); if (res.ok) renderUsers((await res.json()).users || []); } function renderUsers(users) { const box = document.getElementById("people"); box.innerHTML = ""; // Bootstrap mode: the admin's bare invite exists only to create the FIRST member. // Once anyone has joined, adding more happens in-app (members invite each other, // which connects the newcomer via a DM) — so hide the bare invite and point there. const anyJoined = users.some((u) => u.devices > 0); document.getElementById("bootstrapInvite").classList.toggle("hidden", anyJoined); document.getElementById("inviteGuidance").classList.toggle("hidden", !anyJoined); if (!users.length) { box.innerHTML = `

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); }