| 1 | import { randomSecret, deriveInvite } from "/static/crypto.js"; |
| 2 | import { buildInviteFragment } from "/static/invite.js"; |
| 3 | import { qrCanvas } from "/static/qrcode.js"; |
| 4 | import { formatBytes } from "/static/format.js"; |
| 5 | |
| 6 | const 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. |
| 11 | const hash = new URLSearchParams(location.hash.slice(1)); |
| 12 | const fragToken = hash.get("token"); |
| 13 | if (fragToken) { |
| 14 | localStorage.setItem(ADMIN_KEY, fragToken); |
| 15 | history.replaceState(null, "", location.pathname); // scrub URL bar |
| 16 | } |
| 17 | const adminToken = localStorage.getItem(ADMIN_KEY); |
| 18 | |
| 19 | const locked = document.getElementById("locked"); |
| 20 | const console_ = document.getElementById("console"); |
| 21 | |
| 22 | init(); |
| 23 | |
| 24 | async 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 | |
| 55 | async 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. |
| 76 | async 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. |
| 109 | async 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 | |
| 217 | async 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). |
| 253 | function 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. |
| 323 | async 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. |
| 343 | function 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. |
| 461 | function 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 | |
| 482 | function 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 | |
| 493 | function 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 | |
| 502 | function 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 | |
| 552 | function 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). |
| 569 | async 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. |
| 597 | function 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. |
| 626 | async 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 | |
| 655 | async function loadUsers() { |
| 656 | const res = await api("GET", "/api/admin/users"); |
| 657 | if (res.ok) renderUsers((await res.json()).users || []); |
| 658 | } |
| 659 | |
| 660 | function 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. |
| 767 | async 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. |
| 886 | function 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 | |
| 968 | function 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 | |
| 979 | function show(el) { |
| 980 | el.classList.remove("hidden"); |
| 981 | } |
| 982 | |
| 983 | function hide(el) { |
| 984 | el.classList.add("hidden"); |
| 985 | } |
| 986 | |
| 987 | async 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 | |
| 998 | let toastTimer; |
| 999 | function 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 | |