eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import { randomSecret, deriveInvite } from "/static/crypto.js";
2import { buildInviteFragment } from "/static/invite.js";
3import { qrCanvas } from "/static/qrcode.js";
4import { formatBytes } from "/static/format.js";
5
6const ADMIN_KEY = "lchat_admin_token";
7
8// The admin token authorises roster management only — it is NOT a decryption key.
9// There's no instance-wide chat key (each thread has its own); see
10// docs/threads-and-dms.md. The token rides in the #fragment so it isn't sent on load.
11const hash = new URLSearchParams(location.hash.slice(1));
12const fragToken = hash.get("token");
13if (fragToken) {
14 localStorage.setItem(ADMIN_KEY, fragToken);
15 history.replaceState(null, "", location.pathname); // scrub URL bar
16}
17const adminToken = localStorage.getItem(ADMIN_KEY);
18
19const locked = document.getElementById("locked");
20const console_ = document.getElementById("console");
21
22init();
23
24async function init() {
25 if (!adminToken) return show(locked);
26
27 // Verify the token by hitting an admin-only endpoint.
28 const res = await api("GET", "/api/admin/users");
29 if (!res.ok) {
30 localStorage.removeItem(ADMIN_KEY);
31 return show(locked);
32 }
33 hide(locked);
34 show(console_);
35
36 document.getElementById("masterLink").textContent = `${location.origin}/admin#token=${adminToken}`;
37 document.getElementById("copyMaster").onclick = () =>
38 copy(`${location.origin}/admin#token=${adminToken}`, "Admin link copied");
39 wireGroupName();
40 wireServerAvatar();
41 wireUserInvites();
42 wireFeatures();
43 wireUpdates();
44 wireCustomApps();
45 wireTestPush();
46 wireResetInstanceKey();
47 wireCreateAdmin();
48 wireCreate();
49 wireCreateAgent();
50 renderUsers((await res.json()).users || []);
51}
52
53// ---- who can invite -----------------------------------------------------
54
55async function wireUserInvites() {
56 const box = document.getElementById("userInvites");
57 try {
58 const { user_invites } = await (await api("GET", "/api/admin/settings")).json();
59 box.checked = !!user_invites;
60 } catch {}
61 box.onchange = async () => {
62 const res = await api("POST", "/api/admin/settings", { user_invites: box.checked });
63 if (!res.ok) {
64 box.checked = !box.checked; // revert on failure
65 return toast("Couldn't save that");
66 }
67 toast(box.checked ? "Members can now invite people" : "Only you can invite now");
68 };
69}
70
71// ---- feature gates (AI / bots / apps) ------------------------------------
72
73// Each toggle is one partial settings write (like userInvites); the server
74// enforces the gates — the console just flips them. Bots off also hides the
75// "Add an agent" section: minting an agent invite would 403 anyway.
76async function wireFeatures() {
77 const rows = [
78 ["aiEnabled", "ai_enabled", "AI replies are on", "AI replies are off — helpers answer commands only"],
79 ["botsEnabled", "bots_enabled", "Members can create bots again", "Bot creation is off — existing bots keep running"],
80 ["appsEnabled", "apps_enabled", "Apps are back in the New-chat menu", "Apps are off — existing app chats keep working"],
81 ["memberAppsEnabled", "member_apps_enabled", "Members can make their own apps again", "Member-made apps are off — apps already deployed keep working"],
82 ];
83 const agentSection = document.getElementById("agentSection");
84 const syncAgentSection = (botsOn) => agentSection.classList.toggle("hidden", !botsOn);
85 let settings = {};
86 try {
87 settings = await (await api("GET", "/api/admin/settings")).json();
88 } catch {}
89 for (const [domId, key, onMsg, offMsg] of rows) {
90 const box = document.getElementById(domId);
91 box.checked = settings[key] !== false; // default on (older servers omit it)
92 if (key === "bots_enabled") syncAgentSection(box.checked);
93 box.onchange = async () => {
94 const res = await api("POST", "/api/admin/settings", { [key]: box.checked });
95 if (!res.ok) {
96 box.checked = !box.checked; // revert on failure
97 return toast("Couldn't save that");
98 }
99 if (key === "bots_enabled") syncAgentSection(box.checked);
100 toast(box.checked ? onMsg : offMsg);
101 };
102 }
103}
104
105// ---- updates (auto-update settings + what the channel offers) -----------
106
107// Feedback is inline (the status + feedback lines occupy layout space) — no
108// floating toast, per the convention in CLAUDE.md.
109async function wireUpdates() {
110 const auto = document.getElementById("updateAuto");
111 const dl = document.getElementById("updateDownload");
112 const chan = document.getElementById("updateChannel");
113 const major = document.getElementById("updateMajor");
114 const status = document.getElementById("updateStatus");
115 const feedback = document.getElementById("updateFeedback");
116
117 const flash = (msg, isError = false) => {
118 feedback.textContent = msg;
119 feedback.style.color = isError ? "#c0392b" : "";
120 setTimeout(() => {
121 if (feedback.textContent === msg) feedback.textContent = "";
122 }, 4000);
123 };
124
125 const renderStatus = (s) => {
126 const u = s.update || {};
127 let line = `Running build ${u.build || "unknown"}`;
128 if (s.update_pin) {
129 line += ` — pinned to ${s.update_pin}; automatic updates are paused (pick a channel to resume)`;
130 } else if (u.available_version && u.available_version !== u.build) {
131 const label = u.available_semver ? `v${u.available_semver} (${u.available_version})` : u.available_version;
132 line += ` — ${label} is available on ${s.update_channel}`;
133 if (u.major_held) {
134 line += ` (held: it crosses the major version this space stays on — ${s.update_major || "?"})`;
135 } else if (u.bake_hours) {
136 // The supply-chain safety hold: new releases wait before auto-installing.
137 line += ` (auto-installs after a ${u.bake_hours}-hour safety hold)`;
138 }
139 } else if (u.checked_at) {
140 line += ` — up to date on ${s.update_channel}`;
141 } else {
142 line += " — hasn't checked for updates yet";
143 }
144 status.textContent = line + ".";
145 };
146
147 // A pin shows as an extra selected option in the channel picker; choosing a
148 // real channel clears it (one combined write) and the option disappears.
149 const PINNED = "__pinned__";
150 let pinnedOpt = null;
151 const showPinned = (pin) => {
152 if (pinnedOpt) {
153 pinnedOpt.remove();
154 pinnedOpt = null;
155 }
156 if (pin) {
157 pinnedOpt = document.createElement("option");
158 pinnedOpt.value = PINNED;
159 pinnedOpt.textContent = `Pinned to ${pin}`;
160 chan.appendChild(pinnedOpt);
161 chan.value = PINNED;
162 }
163 };
164
165 let prevChannel = "stable";
166 let prevMajor = "";
167 try {
168 const s = await (await api("GET", "/api/admin/settings")).json();
169 auto.checked = !!s.update_auto;
170 dl.checked = !!s.update_download;
171 chan.value = prevChannel = s.update_channel || "stable";
172 major.value = prevMajor = s.update_major || "";
173 showPinned(s.update_pin);
174 renderStatus(s);
175 } catch {}
176
177 const save = async (body, revert) => {
178 const res = await api("POST", "/api/admin/settings", body);
179 if (!res.ok) {
180 revert();
181 flash("Couldn't save that", true);
182 return false;
183 }
184 // Re-read so the status line reflects the (possibly new) channel at once.
185 try {
186 renderStatus(await (await api("GET", "/api/admin/settings")).json());
187 } catch {}
188 flash("Saved");
189 return true;
190 };
191 auto.onchange = () => save({ update_auto: auto.checked }, () => (auto.checked = !auto.checked));
192 dl.onchange = () => save({ update_download: dl.checked }, () => (dl.checked = !dl.checked));
193 chan.onchange = async () => {
194 if (chan.value === PINNED) return; // pinning happens via deploy tooling, not here
195 const revert = () => (chan.value = pinnedOpt ? PINNED : prevChannel);
196 // Picking a channel also clears any pin — that's how updates resume.
197 if (await save({ update_channel: chan.value, update_pin: "" }, revert)) {
198 prevChannel = chan.value;
199 showPinned("");
200 }
201 };
202 major.onchange = async () => {
203 const v = major.value.trim();
204 if (v !== "" && !/^\d{1,4}$/.test(v)) {
205 flash("Major version must be a number — or empty to follow everything.", true);
206 major.value = prevMajor;
207 return;
208 }
209 if (await save({ update_major: v }, () => (major.value = prevMajor))) {
210 prevMajor = major.value = v;
211 }
212 };
213}
214
215// ---- this space (name + optional administrator identity) ----------------
216
217async function wireGroupName() {
218 const input = document.getElementById("groupName");
219 const desc = document.getElementById("groupDesc");
220 const adminName = document.getElementById("adminName");
221 const adminEmail = document.getElementById("adminEmail");
222 try {
223 const { name } = await (await fetch("/api/group")).json();
224 if (name) input.value = name;
225 // The description + admin identity are member-gated on /api/group, so read
226 // them back via the admin-authed settings endpoint instead.
227 const settings = await (await api("GET", "/api/admin/settings")).json();
228 if (settings.description) desc.value = settings.description;
229 if (settings.admin_name) adminName.value = settings.admin_name;
230 if (settings.admin_email) adminEmail.value = settings.admin_email;
231 } catch {}
232 document.getElementById("nameForm").onsubmit = async (e) => {
233 e.preventDefault();
234 const name = input.value.trim();
235 if (!name) return;
236 const res = await api("POST", "/api/admin/group", {
237 name,
238 admin_name: adminName.value.trim(),
239 admin_email: adminEmail.value.trim(),
240 });
241 // The description has its own endpoint (it's also editable in-app from the
242 // About-this-space sheet); save it with the same button.
243 const dres = await api("POST", "/api/group-description", { description: desc.value.trim() });
244 toast(res.ok && dres.ok ? "Saved" : "Couldn't save");
245 };
246}
247
248// ---- server photo -------------------------------------------------------
249
250// The instance branding photo (public, unencrypted — like the group name). The
251// admin picks an image; we downscale it in-browser (small + EXIF-stripped) and
252// POST the raw bytes to /api/admin/server-avatar. Feedback is inline (no toast).
253function wireServerAvatar() {
254 const preview = document.getElementById("serverAvatarPreview");
255 const pick = document.getElementById("serverAvatarPick");
256 const remove = document.getElementById("serverAvatarRemove");
257 const file = document.getElementById("serverAvatarFile");
258 const status = document.getElementById("serverAvatarStatus");
259
260 const setStatus = (msg, isError = false) => {
261 status.textContent = msg || "";
262 status.style.color = isError ? "#c0392b" : "";
263 };
264
265 // Reflect current state: show the photo (cache-busted) + Remove when one is set.
266 async function refresh() {
267 let has = false;
268 try {
269 has = !!(await (await fetch("/api/group")).json()).avatar;
270 } catch {}
271 if (has) {
272 preview.src = "/api/server-avatar?t=" + Date.now();
273 preview.classList.remove("hidden");
274 remove.classList.remove("hidden");
275 pick.textContent = "Change photo";
276 } else {
277 preview.removeAttribute("src");
278 preview.classList.add("hidden");
279 remove.classList.add("hidden");
280 pick.textContent = "Upload photo";
281 }
282 }
283 refresh();
284
285 pick.onclick = () => file.click();
286 file.onchange = async () => {
287 const f = file.files[0];
288 file.value = "";
289 if (!f) return;
290 setStatus("Uploading…");
291 try {
292 const bytes = await downscaleImage(f, 400);
293 const res = await fetch("/api/admin/server-avatar", {
294 method: "POST",
295 headers: { Authorization: "Bearer " + adminToken },
296 body: bytes,
297 });
298 if (!res.ok) throw new Error("upload");
299 setStatus("Server photo saved.");
300 await refresh();
301 } catch {
302 setStatus("Couldn't save that photo. Try a different image.", true);
303 }
304 };
305 remove.onclick = async () => {
306 setStatus("Removing…");
307 const res = await fetch("/api/admin/server-avatar", {
308 method: "DELETE",
309 headers: { Authorization: "Bearer " + adminToken },
310 });
311 if (res.ok) {
312 setStatus("Server photo removed.");
313 await refresh();
314 } else {
315 setStatus("Couldn't remove the photo.", true);
316 }
317 };
318}
319
320// Downscale + re-encode an image to a small JPEG (caps the long edge, strips
321// EXIF/GPS) — mirrors shell.js processImage, kept local so the admin console needs
322// no shared module. Returns the encoded bytes.
323async function downscaleImage(file, maxDim) {
324 const bitmap = await createImageBitmap(file);
325 const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
326 const w = Math.round(bitmap.width * scale);
327 const h = Math.round(bitmap.height * scale);
328 const canvas = document.createElement("canvas");
329 canvas.width = w;
330 canvas.height = h;
331 canvas.getContext("2d").drawImage(bitmap, 0, 0, w, h);
332 const blob = await new Promise((res) => canvas.toBlob(res, "image/jpeg", 0.85));
333 return await blob.arrayBuffer();
334}
335
336// ---- custom apps ---------------------------------------------------------
337
338// Install / remove custom mini-apps (customapps.go). The DB copy this console
339// manages shadows any same-named LCHAT_APPS_DIR file — the console is
340// authoritative; the disk dir is a dev tool. Feedback is inline (appsStatus),
341// never a floating toast, and every server-provided string renders via
342// textContent — app names and metadata are never innerHTML'd.
343function wireCustomApps() {
344 const list = document.getElementById("appsList");
345 const pick = document.getElementById("appPick");
346 const file = document.getElementById("appFile");
347 const nameInput = document.getElementById("appName");
348 const source = document.getElementById("appSource");
349 const installBtn = document.getElementById("appInstall");
350 const status = document.getElementById("appsStatus");
351
352 const setStatus = (msg, isError = false) => {
353 status.textContent = msg || "";
354 status.style.color = isError ? "#c0392b" : "";
355 };
356
357 // Mirrors the server's rule (customAppName in customapps.go) so a bad name
358 // gets a helpful message instead of a bare 400.
359 const NAME_RE = /^[a-z0-9-]+$/;
360
361 async function refresh() {
362 let apps = [];
363 try {
364 const res = await api("GET", "/api/admin/apps");
365 if (res.ok) apps = (await res.json()).apps || [];
366 } catch {}
367 list.replaceChildren();
368 if (!apps.length) {
369 const p = document.createElement("p");
370 p.className = "sub";
371 p.textContent = "No custom apps installed.";
372 list.appendChild(p);
373 return;
374 }
375 // Same row shape as the People list (.invite/.meta/.label/.status), so the
376 // two lists read as one console.
377 for (const a of apps) {
378 const row = document.createElement("div");
379 row.className = "invite";
380 const meta = document.createElement("div");
381 meta.className = "meta";
382 const label = document.createElement("div");
383 label.className = "label mono";
384 label.textContent = a.name;
385 if (a.on_disk) {
386 // Sideloaded from LCHAT_APPS_DIR — the console can't manage the file,
387 // so the row is read-only (no Delete) with a provenance tag.
388 const tag = document.createElement("span");
389 tag.textContent = "ON DISK";
390 tag.className = "bot-tag";
391 label.appendChild(tag);
392 }
393 const status = document.createElement("div");
394 status.className = "status";
395 const updated = `${formatBytes(a.size)} · updated ${new Date(a.updated_at * 1000).toLocaleDateString()}`;
396 status.textContent = a.on_disk ? updated : `v${a.version} · ${updated}`;
397 meta.append(label, status);
398 // The module route serves the stored source as text/javascript, so a plain
399 // link is a source viewer.
400 const view = document.createElement("a");
401 view.className = "linkish";
402 view.textContent = "View source";
403 view.href = `/static/apps-custom/${encodeURIComponent(a.name)}.js`;
404 view.target = "_blank";
405 view.rel = "noopener";
406 row.append(meta, view);
407 if (!a.on_disk) {
408 const del = document.createElement("button");
409 del.type = "button";
410 del.className = "linkish danger";
411 del.textContent = "Delete";
412 del.onclick = async () => {
413 if (!confirm(`Delete the app “${a.name}” for everyone?`)) return;
414 const res = await api("DELETE", `/api/admin/apps/${encodeURIComponent(a.name)}`);
415 setStatus(res.ok ? `Deleted ${a.name}.` : "Couldn't delete that app.", !res.ok);
416 refresh();
417 };
418 row.append(del);
419 }
420 list.appendChild(row);
421 }
422 }
423
424 async function install(name, src) {
425 if (!NAME_RE.test(name)) {
426 return setStatus("App names are lowercase letters, digits, and dashes only.", true);
427 }
428 if (!src.trim()) return setStatus("The app source is empty.", true);
429 setStatus("Installing…");
430 const res = await api("PUT", `/api/admin/apps/${encodeURIComponent(name)}`, { source: src });
431 if (!res.ok) return setStatus("Couldn't install that app.", true);
432 setStatus(`Installed ${name}. Members' open apps refresh to pick it up.`);
433 nameInput.value = "";
434 source.value = "";
435 refresh();
436 }
437
438 pick.onclick = () => file.click();
439 file.onchange = () => {
440 const f = file.files[0];
441 file.value = "";
442 if (!f) return;
443 // The app name comes from the file name (kanban.js → kanban); install()
444 // rejects anything that doesn't survive as a valid name.
445 const name = f.name.replace(/\.js$/i, "").toLowerCase();
446 const reader = new FileReader();
447 reader.onload = () => install(name, String(reader.result || ""));
448 reader.onerror = () => setStatus("Couldn't read that file.", true);
449 reader.readAsText(f);
450 };
451 installBtn.onclick = () => install(nameInput.value.trim(), source.value);
452
453 refresh();
454}
455
456// ---- notifications ------------------------------------------------------
457
458// The #785 break-glass: reset an orphaned shared (instance) key. Guarded by a
459// typed confirmation — this is the one admin action that permanently orphans
460// content (old-key photos/abouts/member apps), so a misclick must not fire it.
461function wireResetInstanceKey() {
462 const status = document.getElementById("resetKeyStatus");
463 document.getElementById("resetInstanceKey").onclick = async () => {
464 const word = prompt(
465 "Reset this space's shared key?\n\n" +
466 "Profile photos, about-me notes and member-built apps locked with the old key are lost for good. " +
467 "Names return automatically; photos and apps must be re-added. Open groups close to invite-only. " +
468 "Messages are untouched.\n\nType RESET to confirm.",
469 );
470 if (word === null) return;
471 if (word.trim().toUpperCase() !== "RESET") {
472 status.textContent = "Not reset — confirmation didn't match.";
473 return;
474 }
475 const res = await api("POST", "/api/admin/instance-key/reset");
476 status.textContent = res.ok
477 ? "Shared key reset. The next member to open the app mints a fresh one."
478 : "Couldn't reset — try again.";
479 };
480}
481
482function wireTestPush() {
483 document.getElementById("testPush").onclick = async () => {
484 const res = await api("POST", "/api/admin/test-push");
485 if (!res.ok) return toast("Couldn't send test");
486 const { sent } = await res.json();
487 toast(`Test sent to ${sent} device(s)`);
488 };
489}
490
491// ---- invites & people ---------------------------------------------------
492
493function wireCreateAdmin() {
494 document.getElementById("createAdminForm").onsubmit = async (e) => {
495 e.preventDefault();
496 const label = document.getElementById("adminLabel").value.trim();
497 // Show confirmation before minting the admin invite
498 showAdminConfirmation(label);
499 };
500}
501
502function showAdminConfirmation(label) {
503 const backdrop = document.createElement("div");
504 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";
505 const card = document.createElement("div");
506 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)";
507
508 const title = document.createElement("h3");
509 title.textContent = "Create admin invite";
510 title.style.cssText = "margin:0 0 16px;font-size:18px;font-weight:700";
511 card.appendChild(title);
512
513 const warning = document.createElement("div");
514 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)";
515 warning.innerHTML = "<strong>⚠️ This is powerful</strong><br/>Anyone who claims this link becomes an admin with full control over this server.";
516 card.appendChild(warning);
517
518 const guidance = document.createElement("p");
519 guidance.style.cssText = "margin:0 0 16px;font-size:13px;color:var(--muted);line-height:1.6";
520 guidance.innerHTML = "Use this to:<br/>• Delegate admin duties to someone you trust<br/>• Recover access if you lose your token<br/><br/>Treat this link like a password — keep it private.";
521 card.appendChild(guidance);
522
523 const row = document.createElement("div");
524 row.style.cssText = "display:flex;gap:8px;margin-top:16px";
525
526 const cancelBtn = document.createElement("button");
527 cancelBtn.className = "secondary";
528 cancelBtn.textContent = "Cancel";
529 cancelBtn.onclick = () => backdrop.remove();
530
531 const confirmBtn = document.createElement("button");
532 confirmBtn.textContent = "Create invite";
533 confirmBtn.onclick = async () => {
534 backdrop.remove();
535 toast("Creating invite…");
536 const minted = await mintInvite(label, null, false, null, true);
537 if (!minted) return;
538 document.getElementById("adminLabel").value = "";
539 showInviteModal(minted.link, label ? `Admin invite for ${label}` : "Admin invite", true);
540 loadUsers();
541 };
542
543 row.append(cancelBtn, confirmBtn);
544 card.appendChild(row);
545 backdrop.appendChild(card);
546 backdrop.addEventListener("click", (e) => {
547 if (e.target === backdrop) backdrop.remove();
548 });
549 document.body.appendChild(backdrop);
550}
551
552function wireCreate() {
553 document.getElementById("createForm").onsubmit = async (e) => {
554 e.preventDefault();
555 const label = document.getElementById("label").value.trim();
556 const tierSel = document.getElementById("inviteTier");
557 const minted = await mintInvite(label, null, false, null, false, (tierSel && tierSel.value) || "");
558 if (!minted) return;
559 document.getElementById("label").value = "";
560 showInviteModal(minted.link, label || "New invite");
561 loadUsers();
562 };
563 loadInviteTiers();
564}
565
566// Cohort invites: when the space has access levels, the invite form offers
567// "add them to <level>" — the claimer lands in that level on join (their
568// level key follows from a holder once they publish a pubkey).
569async function loadInviteTiers() {
570 const sel = document.getElementById("inviteTier");
571 if (!sel) return;
572 let tiers = [];
573 try {
574 tiers = (await (await api("GET", "/api/admin/tiers")).json()).tiers || [];
575 } catch {
576 return; // older server or offline — the picker just stays hidden
577 }
578 if (!tiers.length) return;
579 sel.textContent = "";
580 const none = document.createElement("option");
581 none.value = "";
582 none.textContent = "No access level";
583 sel.appendChild(none);
584 for (const t of tiers) {
585 if (t.key_lost) continue; // a dead level can't receive anyone
586 const o = document.createElement("option");
587 o.value = t.id;
588 o.textContent = `Add them to ${t.name}`;
589 sel.appendChild(o);
590 }
591 sel.classList.remove("hidden");
592}
593
594// Adding an agent is a deliberate, separate action from inviting a person: it
595// mints an invite flagged as an agent, so the joiner is badged as a bot. The
596// sidecar consumes this link to provision the bot's identity.
597function wireCreateAgent() {
598 document.getElementById("agentForm").onsubmit = async (e) => {
599 e.preventDefault();
600 const name = document.getElementById("agentLabel").value.trim();
601 const config = {
602 model: document.getElementById("agentModel").value,
603 system_prompt: document.getElementById("agentPrompt").value.trim(),
604 docs: document.getElementById("agentDocs").value.trim(),
605 listen: document.getElementById("agentListen").value,
606 description: document.getElementById("agentDescription").value.trim(),
607 available_to_members: document.getElementById("agentAvailable").checked,
608 };
609 const minted = await mintInvite(name, null, true, config);
610 if (!minted) return;
611 document.getElementById("agentLabel").value = "";
612 document.getElementById("agentDescription").value = "";
613 document.getElementById("agentModel").value = "";
614 document.getElementById("agentPrompt").value = "";
615 document.getElementById("agentDocs").value = "";
616 document.getElementById("agentListen").value = "";
617 document.getElementById("agentAvailable").checked = true;
618 showInviteModal(minted.link, name || "New agent");
619 loadUsers();
620 };
621}
622
623// Mint a single-use invite: generate the secret T client-side, wrap the group
624// key under it, and store only {id, wrapped} server-side. Pass a userId to
625// re-issue for an existing person (add/recover a device). Returns the link.
626async function mintInvite(label, userId, agent, config, grantsAdmin, tierID) {
627 // Auth-only invite (threads model): no instance-wide key to wrap — the joiner
628 // generates their own user keypair on join. See docs/threads-and-dms.md.
629 const T = randomSecret();
630 const { id, claimHash } = await deriveInvite(T);
631 const res = await api("POST", "/api/admin/key-invites", {
632 // Only agents carry a server-side plaintext name (admin-authored, and shown
633 // as a bot badge). A person's suggested name is NEVER sent to the server —
634 // it rides in the link #fragment below and the joiner publishes it encrypted.
635 label: agent ? label : "",
636 user_id: userId || "",
637 id,
638 wrapped: "",
639 claim_hash: claimHash,
640 agent: !!agent,
641 grants_admin: !!grantsAdmin,
642 tier_id: tierID || "",
643 ...(config || {}), // model, system_prompt, docs — agent only
644 });
645 if (!res.ok) {
646 toast("Couldn't create invite");
647 return null;
648 }
649 // Neutral slug (the path reaches the server); a person's suggested name rides
650 // in the #fragment, which browsers never send to the server.
651 const frag = buildInviteFragment(T, agent ? "" : label);
652 return { link: `${location.origin}/join/join#${frag}` };
653}
654
655async function loadUsers() {
656 const res = await api("GET", "/api/admin/users");
657 if (res.ok) renderUsers((await res.json()).users || []);
658}
659
660function renderUsers(users) {
661 const box = document.getElementById("people");
662 box.innerHTML = "";
663 // Bootstrap mode: the admin's bare invite exists only to create the FIRST member.
664 // Once anyone has joined, adding more happens in-app (members invite each other,
665 // which connects the newcomer via a DM) — so hide the bare invite and point there.
666 const anyJoined = users.some((u) => u.devices > 0);
667 document.getElementById("bootstrapInvite").classList.toggle("hidden", anyJoined);
668 document.getElementById("inviteGuidance").classList.toggle("hidden", !anyJoined);
669 if (!users.length) {
670 box.innerHTML = `<p class="sub">No one yet. Invite the first person above.</p>`;
671 return;
672 }
673 // People are shown only as a count — their names are end-to-end encrypted, so
674 // neither the server nor this console ever sees them. To act on a specific
675 // person (e.g. remove them), do it from the app, where names are visible. Bots
676 // are admin-authored, so they stay named and editable below.
677 const joinedCount = users.filter((u) => !u.is_agent && u.devices > 0).length;
678 const summary = document.createElement("p");
679 summary.className = "sub";
680 summary.textContent = `${joinedCount} ${joinedCount === 1 ? "member" : "members"}`;
681 box.appendChild(summary);
682 for (const u of users) {
683 if (!u.is_agent && u.devices > 0) continue; // joined people are counted, not listed
684 const row = document.createElement("div");
685 row.className = "invite";
686
687 const meta = document.createElement("div");
688 meta.className = "meta";
689 const label = document.createElement("div");
690 label.className = "label";
691 label.textContent = u.is_agent ? u.label || "(bot)" : "Pending invite";
692 if (u.is_agent) {
693 const tag = document.createElement("span");
694 // AI = LLM-backed (what it sees goes to a model provider); BOT = deterministic.
695 tag.textContent = u.kind === "ai" ? "AI" : "BOT";
696 tag.className = "bot-tag";
697 label.appendChild(tag);
698 }
699 const joined = u.devices > 0;
700 const date = (t) => new Date(t * 1000).toLocaleDateString();
701 const status = document.createElement("div");
702 status.className = "status";
703 if (joined) {
704 const when = date(u.joined_at || u.created_at);
705 status.textContent = u.devices > 1 ? `joined ${when} · ${u.devices} devices` : `joined ${when}`;
706 } else if (u.pending) {
707 status.textContent = `invited ${date(u.created_at)} · not joined yet`;
708 } else {
709 status.textContent = `invited ${date(u.created_at)} · link expired`;
710 }
711 meta.appendChild(label);
712 meta.appendChild(status);
713 row.appendChild(meta);
714
715 if (joined) {
716 // The admin CAN'T mint a working add-device link for someone already joined —
717 // only their existing device holds the private key needed to read their threads.
718 // A fresh admin link would make the new device generate a different keypair and
719 // orphan every thread wrapped to the old one. So point them at the in-app flow.
720 const note = document.createElement("div");
721 note.className = "device-note";
722 note.textContent = "To add a device, use “Add a device” in the app on a device they're already on.";
723 row.appendChild(note);
724 } else if (!u.pending_dm) {
725 // A bare (admin) pending invitee: a fresh single-use link is safe — they
726 // generate their keypair on join. (Invitees waiting on a member's in-app DM
727 // invite have pending_dm=true; that link is re-sent from inside the chat by the
728 // inviter, since a bare admin link would drop them into a keyless, dead DM.)
729 const reissue = document.createElement("button");
730 reissue.className = "secondary";
731 reissue.textContent = "Resend link";
732 reissue.onclick = async () => {
733 const minted = await mintInvite("", u.id);
734 if (minted) showInviteModal(minted.link, "Invite");
735 };
736 row.appendChild(reissue);
737 }
738
739 // Bots are editable after creation: persona, model, docs + catalog entry.
740 if (u.is_agent) {
741 const edit = document.createElement("button");
742 edit.className = "secondary";
743 edit.textContent = "Edit bot";
744 edit.onclick = () => showEditBotModal(u);
745 row.appendChild(edit);
746 }
747
748 // Remove a person from the instance (roster management).
749 const remove = document.createElement("button");
750 remove.className = "linkish danger";
751 remove.textContent = "Remove";
752 remove.onclick = async () => {
753 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;
754 const res = await api("POST", `/api/admin/users/${encodeURIComponent(u.id)}/remove`);
755 toast(res.ok ? "Removed" : "Couldn't remove");
756 if (res.ok) loadUsers();
757 };
758 row.appendChild(remove);
759
760 box.appendChild(row);
761 }
762}
763
764// Edit an existing bot's config in place. The sidecar re-pulls config on its
765// next reconnect (~a minute), so persona/model/docs edits apply without a
766// redeploy; catalog changes (description / Helpers visibility) are immediate.
767async function showEditBotModal(u) {
768 const res = await api("GET", `/api/admin/bots/${encodeURIComponent(u.id)}/config`);
769 if (!res.ok) {
770 toast("Couldn't load the bot's config");
771 return;
772 }
773 const cfg = await res.json();
774
775 const backdrop = document.createElement("div");
776 backdrop.className = "qr-screen"; // same full-screen takeover as the QR modal
777 const card = document.createElement("div");
778 card.className = "card qr-modal";
779 const h = document.createElement("h2");
780 h.textContent = `Edit bot: ${u.label}`;
781 card.appendChild(h);
782
783 const field = (el, value) => {
784 el.style.cssText = "display:block;width:100%;margin:0 0 8px";
785 el.value = value || "";
786 card.appendChild(el);
787 return el;
788 };
789 const model = field(document.createElement("select"), "");
790 for (const [v, t] of [
791 ["", "Default model (Sonnet)"],
792 ["claude-opus-4-8", "Opus — most capable"],
793 ["claude-sonnet-5", "Sonnet — balanced"],
794 ["claude-haiku-4-5", "Haiku — fast & cheap"],
795 ]) {
796 const o = document.createElement("option");
797 o.value = v;
798 o.textContent = t;
799 model.appendChild(o);
800 }
801 model.value = cfg.model || "";
802 const desc = field(document.createElement("input"), cfg.description);
803 desc.placeholder = "What this helper does, in one line — members see this in the Helpers list";
804 desc.maxLength = 200;
805 const prompt = field(document.createElement("textarea"), cfg.system_prompt);
806 prompt.rows = 4;
807 prompt.placeholder = "Personality / instructions (system prompt). Leave blank for the bot's default.";
808 const docs = field(document.createElement("textarea"), cfg.docs);
809 docs.rows = 3;
810 docs.placeholder = "Reference the bot should always know. Optional.";
811 const welcome = field(document.createElement("textarea"), cfg.welcome);
812 welcome.rows = 3;
813 welcome.placeholder = "Welcome DM sent to new members (the guide sends this). Use {name} for their name. Blank = default.";
814 const listen = field(document.createElement("select"), "");
815 for (const [v, t] of [
816 ["", "Replies in groups only when @mentioned (default)"],
817 ["name", "Also replies when spoken to by name (“Claude, …”)"],
818 ]) {
819 const o = document.createElement("option");
820 o.value = v;
821 o.textContent = t;
822 listen.appendChild(o);
823 }
824 // 'mention' and '' are the same mode; the form only mints '' and 'name'.
825 listen.value = cfg.listen === "name" ? "name" : "";
826 const availRow = document.createElement("label");
827 availRow.className = "toggle-row";
828 availRow.style.margin = "0 0 8px";
829 const avail = document.createElement("input");
830 avail.type = "checkbox";
831 avail.checked = !!cfg.available_to_members;
832 const availText = document.createElement("span");
833 availText.textContent = "Show in everyone's Helpers list";
834 availRow.appendChild(avail);
835 availRow.appendChild(availText);
836 card.appendChild(availRow);
837
838 const rowEl = document.createElement("div");
839 rowEl.className = "row";
840 const cancel = document.createElement("button");
841 cancel.className = "secondary";
842 cancel.textContent = "Cancel";
843 const save = document.createElement("button");
844 save.textContent = "Save";
845 rowEl.appendChild(cancel);
846 rowEl.appendChild(save);
847 card.appendChild(rowEl);
848
849 const close = () => {
850 backdrop.remove();
851 document.removeEventListener("keydown", onKey);
852 };
853 const onKey = (e) => {
854 if (e.key === "Escape") close();
855 };
856 document.addEventListener("keydown", onKey);
857 cancel.onclick = close;
858 save.onclick = async () => {
859 save.disabled = true;
860 const r = await api("POST", `/api/admin/bots/${encodeURIComponent(u.id)}/config`, {
861 model: model.value,
862 system_prompt: prompt.value.trim(),
863 docs: docs.value.trim(),
864 listen: listen.value,
865 description: desc.value.trim(),
866 welcome: welcome.value.trim(),
867 available_to_members: avail.checked,
868 });
869 if (r.ok) {
870 toast("Saved — the bot picks it up within a minute");
871 close();
872 } else {
873 toast("Couldn't save");
874 save.disabled = false;
875 }
876 };
877
878 backdrop.appendChild(card);
879 document.body.appendChild(backdrop);
880}
881
882// Show a scannable QR of an invite link, for joining in person. The QR encodes
883// the FULL join link — including the #k= key fragment — so it's rendered here in
884// the browser (never server-side, which would leak the key) and must be treated
885// like the secret link: anyone who scans it can read the whole chat.
886function showInviteModal(link, title) {
887 // Full-screen, opaque takeover — hides the rest of the admin console so only
888 // the QR is on screen (easier to scan, nothing sensitive shown behind it).
889 const backdrop = document.createElement("div");
890 backdrop.className = "qr-screen";
891 const card = document.createElement("div");
892 card.className = "card qr-modal";
893
894 const h = document.createElement("h2");
895 h.textContent = title ? `Invite: ${title}` : "Invite";
896 card.appendChild(h);
897
898 const note = document.createElement("p");
899 note.className = "sub";
900 note.textContent =
901 "Scan to join. Anyone who scans it can read the whole chat, so treat it like the secret link.";
902 card.appendChild(note);
903
904 const wrap = document.createElement("div");
905 wrap.className = "qr-wrap";
906 try {
907 wrap.appendChild(qrCanvas(link, { scale: 6, margin: 4 }));
908 } catch {
909 wrap.textContent = "This link is too long to show as a QR code.";
910 }
911 card.appendChild(wrap);
912
913 // Show the full link too, so it can be selected and copied by hand if the
914 // clipboard button is blocked. Tap to select all. It IS the secret — same
915 // caveat as the QR: anyone with it can read the whole chat.
916 const linkBox = document.createElement("div");
917 linkBox.className = "key-box mono";
918 linkBox.textContent = link;
919 linkBox.onclick = () => {
920 const range = document.createRange();
921 range.selectNodeContents(linkBox);
922 const sel = window.getSelection();
923 sel.removeAllRanges();
924 sel.addRange(range);
925 };
926 card.appendChild(linkBox);
927
928 const close = () => {
929 backdrop.remove();
930 document.removeEventListener("keydown", onKey);
931 };
932 const onKey = (e) => {
933 if (e.key === "Escape") close();
934 };
935
936 const row = document.createElement("div");
937 row.className = "row";
938 const copyBtn = document.createElement("button");
939 copyBtn.className = "secondary";
940 copyBtn.textContent = "Copy link";
941 // In-modal feedback: a toast would render behind this full-screen overlay.
942 copyBtn.onclick = async () => {
943 try {
944 await navigator.clipboard.writeText(link);
945 copyBtn.textContent = "Link copied ✓";
946 } catch {
947 copyBtn.textContent = "Select the link above to copy";
948 }
949 setTimeout(() => (copyBtn.textContent = "Copy link"), 2000);
950 };
951 const doneBtn = document.createElement("button");
952 doneBtn.textContent = "Done";
953 doneBtn.onclick = close;
954 row.appendChild(copyBtn);
955 row.appendChild(doneBtn);
956 card.appendChild(row);
957
958 backdrop.appendChild(card);
959 backdrop.addEventListener("click", (e) => {
960 if (e.target === backdrop) close();
961 });
962 document.addEventListener("keydown", onKey);
963 document.body.appendChild(backdrop);
964}
965
966// ---- helpers ------------------------------------------------------------
967
968function api(method, path, body) {
969 return fetch(path, {
970 method,
971 headers: {
972 Authorization: "Bearer " + adminToken,
973 ...(body ? { "Content-Type": "application/json" } : {}),
974 },
975 body: body ? JSON.stringify(body) : undefined,
976 });
977}
978
979function show(el) {
980 el.classList.remove("hidden");
981}
982
983function hide(el) {
984 el.classList.add("hidden");
985}
986
987async function copy(text, msg) {
988 try {
989 await navigator.clipboard.writeText(text);
990 toast(msg);
991 } catch {
992 // Clipboard blocked (e.g. lost the user gesture) — no blocking dialog;
993 // the link is in the list with its own Copy button.
994 toast("Created — tap “Copy link” to copy it");
995 }
996}
997
998let toastTimer;
999function toast(msg) {
1000 const t = document.getElementById("toast");
1001 t.textContent = msg;
1002 t.classList.add("show");
1003 clearTimeout(toastTimer);
1004 toastTimer = setTimeout(() => t.classList.remove("show"), 1800);
1005}
1006