| 1 | // The Admin app — the in-app replacement for the /admin console page |
| 2 | // (docs/conversational-onboarding.md §2). Admin-only: it's system-scope (never |
| 3 | // offered in the Apps menu); the ways in are the `/admin` command and the setup |
| 4 | // conversation's "Open admin" chip, both gated on the caller's role. |
| 5 | // |
| 6 | // Like the setup conversation (and unlike ordinary mini-apps), this is a |
| 7 | // HOST-OWNED surface: instance settings are real server state, not thread |
| 8 | // events, so everything reads and writes through the host bridge |
| 9 | // (setSetupBridge in shell.js) — the app itself holds no tokens and no fetch |
| 10 | // calls of its own. The one thing that DOES live in the thread's event log is |
| 11 | // the update stream: release notes the app posts (admin.note), read in the |
| 12 | // companion Chat view — see announceNew below. |
| 13 | import { defineApp, namespace, setupBridge, registerFloorCommand, openAppThread } from "../apps.js"; |
| 14 | import { ICON_WRENCH } from "../icons.js"; |
| 15 | import { changelogParagraphs } from "../format.js"; |
| 16 | |
| 17 | // One declaration of the app's prefix, reused everywhere: N.cls("root") → the |
| 18 | // "admin-root" CSS class. Change "admin" here and every class string below |
| 19 | // follows — the prefix can't drift. |
| 20 | const N = namespace("admin"); |
| 21 | |
| 22 | // A textarea that tracks its content height (the composer's behaviour): no |
| 23 | // inner scrollbar, the field is as tall as what's written. Returns the fit |
| 24 | // function so callers can re-run it after setting .value programmatically — |
| 25 | // an input event only fires for typing. |
| 26 | function fitHeight(ta) { |
| 27 | const fit = () => { |
| 28 | ta.style.height = "auto"; |
| 29 | ta.style.height = ta.scrollHeight + 2 + "px"; // +2: the two 1px borders (border-box) |
| 30 | }; |
| 31 | ta.style.resize = "none"; |
| 32 | ta.style.overflow = "hidden"; |
| 33 | ta.addEventListener("input", fit); |
| 34 | // A width change re-wraps the text (rows stack on narrow panes, panes |
| 35 | // resize), so re-fit on any size change; the initial observe callback also |
| 36 | // covers first layout. Settles immediately: re-applying the same height |
| 37 | // stops the observer cascade. |
| 38 | if (typeof ResizeObserver !== "undefined") new ResizeObserver(fit).observe(ta); |
| 39 | else requestAnimationFrame(fit); |
| 40 | return fit; |
| 41 | } |
| 42 | |
| 43 | // ── Release notes → the Chat view ───────────────────────────────────────── |
| 44 | // In place of the retired Announcer bot, the Admin app POSTS each new release |
| 45 | // note into this thread as an ordinary encrypted event; chatMessage projects it |
| 46 | // as a bubble from "Admin", so updates read as a conversation — the same |
| 47 | // one-log-two-views model as To-do, with the chat side read-only (chatReadOnly). |
| 48 | // State is just the ids already posted; the first-ever run baselines silently |
| 49 | // (records everything current, posts nothing) so a new admin isn't greeted by |
| 50 | // the whole backlog — exactly the Announcer's first-tick behaviour. |
| 51 | // |
| 52 | // Runs once per thread per page load, when the app renders. The check needs |
| 53 | // state AFTER the history fold, which is exactly when render() first fires. |
| 54 | // |
| 55 | // An emit on a closed socket returns false — and right after a reload the |
| 56 | // Admin thread often renders BEFORE the WebSocket is up (it reopens as the |
| 57 | // active thread during boot). An idle thread may never render again (no events |
| 58 | // arrive to trigger one), so a failed send can't wait for the next render: it |
| 59 | // retries here, briefly, re-reading state each attempt. `posted` guards the |
| 60 | // same pass re-sending an entry whose echo hasn't folded back yet. |
| 61 | const announcedFor = new Set(); |
| 62 | // The one live billing-changed hook — each render swaps its own closure in, |
| 63 | // so the OAuth return leg repaints the CURRENT payments row, never a stale one. |
| 64 | let billingHook = null; |
| 65 | async function announceNew(ctx) { |
| 66 | if (announcedFor.has(ctx.threadId)) return; |
| 67 | announcedFor.add(ctx.threadId); |
| 68 | try { |
| 69 | const bridge = setupBridge(); |
| 70 | if (!bridge || !bridge.changelog) return; |
| 71 | const entries = await bridge.changelog(); // oldest-first, [] on failure |
| 72 | if (!entries.length) return; |
| 73 | const posted = new Set(); |
| 74 | for (let attempt = 0; attempt < 15; attempt++) { |
| 75 | if (attempt) await new Promise((r) => setTimeout(r, 2000)); |
| 76 | // getState() is undefined until the thread's FIRST event folds — and on a |
| 77 | // brand-new Admin thread the baseline we're about to post IS that first |
| 78 | // event, so guard the empty store. |
| 79 | const seen = new Set((ctx.getState() || {}).seen || []); |
| 80 | let unsent = false; |
| 81 | if (!seen.size && !posted.size) { |
| 82 | // First run: baseline silently — record everything current, post nothing. |
| 83 | if (await ctx.emit({ type: "admin.baseline", ids: entries.map((e) => e.id) })) return; |
| 84 | unsent = true; |
| 85 | } else { |
| 86 | for (const e of entries) { |
| 87 | if (!e.id || seen.has(e.id) || posted.has(e.id)) continue; |
| 88 | const text = changelogParagraphs(e.text).join("\n\n"); |
| 89 | if (!text) continue; |
| 90 | if (await ctx.emit({ type: "admin.note", id: e.id, text })) posted.add(e.id); |
| 91 | else { |
| 92 | unsent = true; |
| 93 | break; |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | if (!unsent) return; |
| 98 | } |
| 99 | announcedFor.delete(ctx.threadId); // still unsent after ~30s — a later render retries |
| 100 | } catch { |
| 101 | announcedFor.delete(ctx.threadId); |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | // /admin works from any chat (like /tutorial): gated on the caller's role, it |
| 106 | // answers in place for non-admins and opens the panel for admins. |
| 107 | registerFloorCommand("/admin", { |
| 108 | desc: "Open the admin panel (admins only)", |
| 109 | run: async (_args, _threadId, reply) => { |
| 110 | const bridge = setupBridge(); |
| 111 | if (!bridge || !(await bridge.isAdmin())) { |
| 112 | reply({ text: "Only this space's admins can open the admin panel." }); |
| 113 | return; |
| 114 | } |
| 115 | openAppThread("admin"); |
| 116 | }, |
| 117 | }); |
| 118 | |
| 119 | export const app = defineApp({ |
| 120 | name: "admin", |
| 121 | title: "Admin", |
| 122 | glyph: "🛠️", |
| 123 | symbol: "wrench.adjustable", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md) |
| 124 | logo: ICON_WRENCH, |
| 125 | places: [], // host-mounted (the admin thread) — never user-instantiable |
| 126 | snapshot: false, |
| 127 | // A companion app that leads with its Interface: opening Admin shows the |
| 128 | // settings panel (not the split), with the read-only update stream one |
| 129 | // Chat-toggle away. |
| 130 | defaultView: "interface", |
| 131 | chatReadOnly: true, |
| 132 | initialState: () => ({ seen: [] }), |
| 133 | events: { |
| 134 | // One event per release note posted into this thread; `seen` (the posted |
| 135 | // ids) is the cross-device watermark deciding what's still new. |
| 136 | "admin.note": (s, e) => |
| 137 | typeof e.id === "string" && e.id && !(s.seen || []).includes(e.id) ? { seen: [...(s.seen || []), e.id] } : s, |
| 138 | // First-run baseline: mark everything current as seen, posting nothing. |
| 139 | "admin.baseline": (s, e) => |
| 140 | Array.isArray(e.ids) ? { seen: [...new Set([...(s.seen || []), ...e.ids.filter((x) => typeof x === "string" && x)])] } : s, |
| 141 | // A report-arrival line (issue #578): the bubble in the Chat view IS the |
| 142 | // record — shell.js posts one per server-throttled burst; no state folds. |
| 143 | "admin.report": (s) => s, |
| 144 | }, |
| 145 | |
| 146 | // The Admin thread's dot lights for report lines even though your own |
| 147 | // client wrote them (the shell's live path skips own messages; the |
| 148 | // load-time path consults this hook, so the dot survives a reload too). |
| 149 | unread: (e) => e.type === "admin.report", |
| 150 | |
| 151 | // A posted note reads as a message from Admin; the baseline is silent |
| 152 | // housekeeping. This hook is also the companion-view opt-in (see apps.js). |
| 153 | chatMessage(e) { |
| 154 | if (e.type === "admin.note" && e.text) return e.text; |
| 155 | if (e.type === "admin.report") { |
| 156 | const n = Number(e.n) || 1; |
| 157 | return n > 1 |
| 158 | ? `⚠️ A report came in — ${n} are waiting in Reports.` |
| 159 | : "⚠️ A report came in — it's waiting in Reports."; |
| 160 | } |
| 161 | return null; |
| 162 | }, |
| 163 | |
| 164 | // The empty-state intro shown at the top of the Chat view. |
| 165 | chatIntro: "Updates about this space are posted here — you'll see the release notes whenever your space updates itself.", |
| 166 | |
| 167 | styles: ` |
| 168 | /* width:100% — as a flex item with auto margins the root SHRINK-TO-FITS, so |
| 169 | one unshrinkable row used to size the whole page past the pane and the |
| 170 | admin panel side-scrolled on phones. Also the container the row rules |
| 171 | below query against. */ |
| 172 | .admin-root { |
| 173 | max-width: 480px; width: 100%; margin: 0 auto; padding: 28px 20px 48px; |
| 174 | container-type: inline-size; |
| 175 | } |
| 176 | |
| 177 | /* The one card on this page: a live miniature of the invitation card — the |
| 178 | actual surface these settings control (branding renders where no key exists |
| 179 | yet). Editing below updates it as you type. */ |
| 180 | .admin-preview { |
| 181 | display: flex; flex-direction: column; align-items: center; text-align: center; |
| 182 | padding: 28px 22px 24px; border-radius: 22px; |
| 183 | background: var(--panel); |
| 184 | border: 1px solid color-mix(in srgb, var(--ink) 8%, transparent); |
| 185 | box-shadow: 0 10px 34px color-mix(in srgb, var(--ink) 10%, transparent); |
| 186 | } |
| 187 | .admin-preview-photo { |
| 188 | width: 64px; height: 64px; border-radius: 16px; object-fit: cover; margin-bottom: 12px; |
| 189 | } |
| 190 | .admin-preview-glyph { font-size: 44px; line-height: 64px; margin-bottom: 12px; } |
| 191 | .admin-preview-title { font-size: 18px; font-weight: 700; color: var(--ink); overflow-wrap: anywhere; } |
| 192 | .admin-preview-sub { font-size: 13px; color: var(--muted); margin-top: 4px; } |
| 193 | /* mirrors .welcome-desc on the real join card: the description is content to |
| 194 | read, not chrome — a tinted panel with full-strength text. */ |
| 195 | .admin-preview-desc { |
| 196 | font-size: 12px; color: var(--ink); text-align: left; |
| 197 | background: color-mix(in srgb, var(--accent) 8%, transparent); |
| 198 | border-radius: 10px; padding: 8px 10px; margin-top: 8px; |
| 199 | white-space: pre-wrap; overflow-wrap: anywhere; |
| 200 | } |
| 201 | .admin-preview-caption { |
| 202 | text-align: center; font-size: 12px; color: var(--muted); margin: 10px 0 6px; |
| 203 | } |
| 204 | |
| 205 | /* Sections sit unboxed on the page: a small-caps eyebrow over a hairline — |
| 206 | quiet structure, so the preview card stays the one loud thing. */ |
| 207 | .admin-group { border-top: 1px solid var(--border); margin-top: 26px; padding-top: 16px; } |
| 208 | .admin-eyebrow { |
| 209 | font-size: 11px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; |
| 210 | color: var(--muted); margin: 0 0 12px; |
| 211 | } |
| 212 | .admin-row { display: flex; gap: 10px; align-items: center; margin: 10px 0; flex-wrap: wrap; } |
| 213 | .admin-row label { flex: 0 0 104px; font-size: 14px; color: var(--muted); } |
| 214 | .admin-row input[type="text"], .admin-row input[type="email"], .admin-row select, .admin-row textarea { |
| 215 | flex: 1 1 160px; min-width: 0; font: inherit; padding: 8px 12px; |
| 216 | border: 1px solid var(--border); border-radius: 10px; background: transparent; color: var(--ink); |
| 217 | } |
| 218 | /* Textareas autogrow (the fitHeight helper owns the height); the floor keeps |
| 219 | an empty field inviting. lh needs a current browser — without it the floor |
| 220 | is just absent and autogrow still works. */ |
| 221 | .admin-row textarea { resize: none; overflow: hidden; min-height: calc(3lh + 18px); } |
| 222 | .admin-row-top { align-items: flex-start; } /* textarea rows: label hugs the top */ |
| 223 | .admin-row-top label { padding-top: 8px; } |
| 224 | /* Narrow pane (phones, the Mac's split sidebar): the label column doesn't fit |
| 225 | beside its field — stack each row instead. The switch rows are untouched |
| 226 | (their label IS the row; the more-specific rule below keeps them one line). |
| 227 | The query measures the root's CONTENT box (its 40px padding excluded), so |
| 228 | the full 480px root reads as 440 — 420 keeps desktop side-by-side. */ |
| 229 | @container (max-width: 420px) { |
| 230 | .admin-row label { flex: 1 1 100%; } |
| 231 | .admin-row-top label { padding-top: 0; } |
| 232 | } |
| 233 | .admin-row input:focus-visible, .admin-row textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; } |
| 234 | .admin-row button { flex: none; } |
| 235 | .admin-status { font-size: 13px; color: var(--muted); min-height: 1.2em; } |
| 236 | .admin-photo { width: 56px; height: 56px; border-radius: 12px; object-fit: cover; border: 1px solid var(--border); } |
| 237 | .admin-row-end { justify-content: flex-end; } |
| 238 | /* More specific than the generic .admin-row label column rule above — the |
| 239 | switch's label IS the whole row (sentence left, switch right). */ |
| 240 | .admin-row .admin-switch-label { display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 14px; flex: 1 1 auto; min-width: 0; } |
| 241 | .admin-switch-text { flex: 1 1 auto; } |
| 242 | .admin-switch { position: relative; width: 42px; height: 26px; flex: none; } |
| 243 | .admin-switch input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; } |
| 244 | .admin-switch .knob { |
| 245 | position: absolute; inset: 0; border-radius: 999px; pointer-events: none; |
| 246 | background: color-mix(in srgb, var(--ink) 22%, transparent); transition: background 0.15s ease; |
| 247 | } |
| 248 | .admin-switch .knob::after { |
| 249 | content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; |
| 250 | border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); transition: left 0.15s ease; |
| 251 | } |
| 252 | .admin-switch input:checked + .knob { background: var(--accent); } |
| 253 | .admin-switch input:checked + .knob::after { left: 19px; } |
| 254 | @media (prefers-reduced-motion: reduce) { |
| 255 | .admin-switch .knob, .admin-switch .knob::after { transition: none; } |
| 256 | } |
| 257 | `, |
| 258 | |
| 259 | // Full-pane takeover. render() is sync; the data arrives via the bridge, so |
| 260 | // each section draws its skeleton and fills itself in. |
| 261 | render(state, el, ctx) { |
| 262 | const bridge = setupBridge(); |
| 263 | const root = document.createElement("div"); |
| 264 | root.className = N.cls("root"); |
| 265 | el.appendChild(root); |
| 266 | if (!bridge) return; |
| 267 | |
| 268 | // Post any release notes that shipped since the last visit into the Chat |
| 269 | // view (this thread's read-only stream) — see announceNew below. |
| 270 | announceNew(ctx); |
| 271 | |
| 272 | const section = (title) => { |
| 273 | const s = document.createElement("div"); |
| 274 | s.className = N.cls("group"); |
| 275 | const h = document.createElement("h2"); |
| 276 | h.className = N.cls("eyebrow"); |
| 277 | h.textContent = title; |
| 278 | s.appendChild(h); |
| 279 | root.appendChild(s); |
| 280 | return s; |
| 281 | }; |
| 282 | const statusLine = (parent) => { |
| 283 | const p = document.createElement("div"); |
| 284 | p.className = N.cls("status"); |
| 285 | parent.appendChild(p); |
| 286 | return p; |
| 287 | }; |
| 288 | const flash = (elm, msg) => { |
| 289 | elm.textContent = msg; |
| 290 | setTimeout(() => { |
| 291 | if (elm.textContent === msg) elm.textContent = ""; |
| 292 | }, 2500); |
| 293 | }; |
| 294 | // A setting-as-a-sentence row: text on the left, sliding switch on the |
| 295 | // right. Returns the checkbox for wiring. |
| 296 | const switchRow = (parent, text) => { |
| 297 | const row = document.createElement("div"); |
| 298 | row.className = N.cls("row"); |
| 299 | const wrap = document.createElement("label"); |
| 300 | wrap.className = N.cls("switch-label"); |
| 301 | const t = document.createElement("span"); |
| 302 | t.className = N.cls("switch-text"); |
| 303 | t.textContent = text; |
| 304 | const sw = document.createElement("span"); |
| 305 | sw.className = N.cls("switch"); |
| 306 | const check = document.createElement("input"); |
| 307 | check.type = "checkbox"; |
| 308 | const knob = document.createElement("span"); |
| 309 | knob.className = "knob"; |
| 310 | sw.append(check, knob); |
| 311 | wrap.append(t, sw); |
| 312 | row.appendChild(wrap); |
| 313 | parent.appendChild(row); |
| 314 | return check; |
| 315 | }; |
| 316 | |
| 317 | // ---- The invitation preview: what these settings actually control ------- |
| 318 | // A live miniature of the join card (the one keyless surface): the photo and |
| 319 | // name here are exactly what an invited person sees, so edits below show |
| 320 | // their effect the moment you type. |
| 321 | const preview = document.createElement("div"); |
| 322 | preview.className = N.cls("preview"); |
| 323 | const previewGlyph = document.createElement("div"); |
| 324 | previewGlyph.className = N.cls("preview-glyph"); |
| 325 | previewGlyph.textContent = "💬"; |
| 326 | const previewPhoto = document.createElement("img"); |
| 327 | previewPhoto.className = N.cls("preview-photo"); |
| 328 | previewPhoto.alt = ""; |
| 329 | previewPhoto.style.display = "none"; |
| 330 | const previewTitle = document.createElement("div"); |
| 331 | previewTitle.className = N.cls("preview-title"); |
| 332 | const previewSub = document.createElement("div"); |
| 333 | previewSub.className = N.cls("preview-sub"); |
| 334 | previewSub.textContent = "Private, end-to-end encrypted chat."; |
| 335 | // The space description renders on the real join card (below the sub line), |
| 336 | // so the miniature mirrors it — live as the admin types, hidden when empty. |
| 337 | const previewDesc = document.createElement("div"); |
| 338 | previewDesc.className = "admin-preview-desc"; |
| 339 | previewDesc.style.display = "none"; |
| 340 | preview.append(previewGlyph, previewPhoto, previewTitle, previewSub, previewDesc); |
| 341 | root.appendChild(preview); |
| 342 | const previewCaption = document.createElement("div"); |
| 343 | previewCaption.className = N.cls("preview-caption"); |
| 344 | previewCaption.textContent = "What people see when you invite them"; |
| 345 | root.appendChild(previewCaption); |
| 346 | |
| 347 | const paintPreview = (name, hasPhoto, alt) => { |
| 348 | previewTitle.textContent = "You're invited to " + (name || "your space"); |
| 349 | previewGlyph.style.display = hasPhoto ? "none" : ""; |
| 350 | previewPhoto.style.display = hasPhoto ? "" : "none"; |
| 351 | if (hasPhoto) { |
| 352 | // Via the bridge: while sealed the raw endpoint serves ciphertext, |
| 353 | // and the shell hands back a decrypted object URL instead. |
| 354 | bridge.serverAvatarURL().then((url) => { |
| 355 | if (url) previewPhoto.src = url; |
| 356 | else { |
| 357 | previewGlyph.style.display = ""; |
| 358 | previewPhoto.style.display = "none"; |
| 359 | } |
| 360 | }); |
| 361 | previewPhoto.alt = alt || ""; |
| 362 | } |
| 363 | }; |
| 364 | |
| 365 | // ---- This space: name + contact, saved together ------------------------- |
| 366 | const space = section("This space"); |
| 367 | const nameRow = document.createElement("div"); |
| 368 | nameRow.className = N.cls("row"); |
| 369 | const nameLabel = document.createElement("label"); |
| 370 | nameLabel.textContent = "Name"; |
| 371 | const nameInput = document.createElement("input"); |
| 372 | nameInput.type = "text"; |
| 373 | nameInput.maxLength = 60; |
| 374 | nameRow.append(nameLabel, nameInput); |
| 375 | space.appendChild(nameRow); |
| 376 | const photoState = { has: false, alt: "" }; |
| 377 | nameInput.addEventListener("input", () => paintPreview(nameInput.value.trim(), photoState.has, photoState.alt)); |
| 378 | |
| 379 | const descRow = document.createElement("div"); |
| 380 | descRow.className = "admin-row admin-row-top"; |
| 381 | const descLabel = document.createElement("label"); |
| 382 | descLabel.textContent = "Description"; |
| 383 | const descInput = document.createElement("textarea"); |
| 384 | descInput.rows = 3; |
| 385 | descInput.maxLength = 500; |
| 386 | descInput.placeholder = "What is this space? Members and the people they invite can read this."; |
| 387 | const descFit = fitHeight(descInput); |
| 388 | descRow.append(descLabel, descInput); |
| 389 | space.appendChild(descRow); |
| 390 | const paintPreviewDesc = () => { |
| 391 | const d = descInput.value.trim(); |
| 392 | previewDesc.textContent = d; |
| 393 | previewDesc.style.display = d ? "" : "none"; |
| 394 | }; |
| 395 | descInput.addEventListener("input", paintPreviewDesc); |
| 396 | |
| 397 | const emailRow = document.createElement("div"); |
| 398 | emailRow.className = N.cls("row"); |
| 399 | const emailLabel = document.createElement("label"); |
| 400 | emailLabel.textContent = "Contact email"; |
| 401 | const emailInput = document.createElement("input"); |
| 402 | emailInput.type = "email"; |
| 403 | emailRow.append(emailLabel, emailInput); |
| 404 | space.appendChild(emailRow); |
| 405 | |
| 406 | const saveRow = document.createElement("div"); |
| 407 | saveRow.className = `${N.cls("row")} ${N.cls("row-end")}`; |
| 408 | const save = document.createElement("button"); |
| 409 | save.textContent = "Save"; |
| 410 | saveRow.appendChild(save); |
| 411 | space.appendChild(saveRow); |
| 412 | const spaceStatus = statusLine(space); |
| 413 | |
| 414 | save.onclick = async () => { |
| 415 | const result = await bridge.saveSpace({ |
| 416 | name: nameInput.value.trim(), |
| 417 | email: emailInput.value.trim(), |
| 418 | description: descInput.value.trim(), |
| 419 | }); |
| 420 | flash( |
| 421 | spaceStatus, |
| 422 | result === "ok" |
| 423 | ? "Saved." |
| 424 | : result === "invalid" |
| 425 | ? "That didn't save — names are 1–60 characters, and the email has to look like one." |
| 426 | : "Couldn't save — try again.", |
| 427 | ); |
| 428 | }; |
| 429 | |
| 430 | // ---- Permissions --------------------------------------------------------- |
| 431 | const perms = section("Permissions"); |
| 432 | const invCheck = switchRow(perms, "Members can invite people"); |
| 433 | const permsStatus = statusLine(perms); |
| 434 | |
| 435 | invCheck.onchange = async () => { |
| 436 | const want = invCheck.checked; |
| 437 | const ok = await bridge.setUserInvites(want); |
| 438 | if (ok) { |
| 439 | flash(permsStatus, want ? "Members can now invite people." : "Only admins can invite now."); |
| 440 | } else { |
| 441 | invCheck.checked = !want; // revert — the server didn't take it |
| 442 | flash(permsStatus, "Couldn't save — try again."); |
| 443 | } |
| 444 | }; |
| 445 | |
| 446 | // ---- Privacy: sealed branding (docs/no-plaintext.md) ------------------------ |
| 447 | // One switch: lock the space's name, description, photo, and admin contact |
| 448 | // into a sealed blob only link-holders can open. The shell does all crypto; |
| 449 | // this is pure UI over bridge.brandingSeal/brandingUnseal. |
| 450 | const sealSec = section("Privacy"); |
| 451 | const sealDesc = document.createElement("p"); |
| 452 | sealDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px"; |
| 453 | sealDesc.textContent = |
| 454 | "Normally the space's name, photo, and description sit on the server so invite pages can show them. " + |
| 455 | "Sealing locks them in a box only your invite links can open — the people who run the server are never sent a key. " + |
| 456 | "Messages and photos people share are always locked, either way. " + |
| 457 | "For now the iPhone and Mac apps can't open the box yet, so they show a generic name and no photo while sealed."; |
| 458 | sealSec.appendChild(sealDesc); |
| 459 | const sealCheck = switchRow(sealSec, "Seal the space's name and photo"); |
| 460 | const sealStatus = statusLine(sealSec); |
| 461 | sealCheck.disabled = true; |
| 462 | bridge.brandingSealed().then((sealed) => { |
| 463 | sealCheck.checked = sealed; |
| 464 | sealCheck.disabled = false; |
| 465 | }); |
| 466 | sealCheck.onchange = async () => { |
| 467 | const want = sealCheck.checked; |
| 468 | sealCheck.disabled = true; |
| 469 | const r = want ? await bridge.brandingSeal() : await bridge.brandingUnseal(); |
| 470 | sealCheck.disabled = false; |
| 471 | if (r === "ok") { |
| 472 | flash( |
| 473 | sealStatus, |
| 474 | want |
| 475 | ? "Sealed. New invite links carry the key; the server holds only the locked box." |
| 476 | : "Unsealed. The name and description are readable on the server again — re-add the photo if you had one.", |
| 477 | ); |
| 478 | } else { |
| 479 | sealCheck.checked = !want; // revert — the server didn't take it |
| 480 | flash(sealStatus, "Couldn't change the seal — try again."); |
| 481 | } |
| 482 | }; |
| 483 | |
| 484 | // ---- Features -------------------------------------------------------------- |
| 485 | // The per-instance gates (default on). Each switch is one partial write; |
| 486 | // the server enforces the gate — these entry points just follow suit on |
| 487 | // members' next load. |
| 488 | const featSec = section("Features"); |
| 489 | const featDesc = document.createElement("p"); |
| 490 | featDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px"; |
| 491 | featDesc.textContent = |
| 492 | "Switch off the parts of the app your space doesn't want. Existing chats keep working either way — turning something off just stops new use."; |
| 493 | featSec.appendChild(featDesc); |
| 494 | const featRow = (label, key, onMsg, offMsg) => { |
| 495 | const check = switchRow(featSec, label); |
| 496 | check.onchange = async () => { |
| 497 | const want = check.checked; |
| 498 | if (await bridge.setFeatures({ [key]: want })) { |
| 499 | flash(featStatus, want ? onMsg : offMsg); |
| 500 | } else { |
| 501 | check.checked = !want; // revert — the server didn't take it |
| 502 | flash(featStatus, "Couldn't save — try again."); |
| 503 | } |
| 504 | }; |
| 505 | return check; |
| 506 | }; |
| 507 | const aiCheck = featRow( |
| 508 | "Enable AI", |
| 509 | "ai_enabled", |
| 510 | "AI replies are on.", |
| 511 | "AI replies are off — helpers answer commands only.", |
| 512 | ); |
| 513 | const botsCheck = featRow( |
| 514 | "Enable bots", |
| 515 | "bots_enabled", |
| 516 | "Members can create bots again.", |
| 517 | "Bot creation is off — existing bots keep running.", |
| 518 | ); |
| 519 | const appsCheck = featRow( |
| 520 | "Enable apps", |
| 521 | "apps_enabled", |
| 522 | "Apps are back in the New-chat menu.", |
| 523 | "Apps are off — existing app chats keep working.", |
| 524 | ); |
| 525 | const memberAppsCheck = featRow( |
| 526 | "Let members make their own apps", |
| 527 | "member_apps_enabled", |
| 528 | "Members can make their own apps again.", |
| 529 | "Member-made apps are off — apps already deployed keep working.", |
| 530 | ); |
| 531 | // Inverted semantics on purpose: ON = restricted. The registry is E2EE |
| 532 | // fold state, so this is client-honored policy, not a server wall — the |
| 533 | // sheet, @everyone, and read-time filtering all respect it. |
| 534 | const spaceCheck = featRow( |
| 535 | "Space-wide apps: admins only", |
| 536 | "space_installs_admins", |
| 537 | "Only admins can add apps for everyone now.", |
| 538 | "Anyone can add apps for everyone again.", |
| 539 | ); |
| 540 | const featStatus = statusLine(featSec); |
| 541 | |
| 542 | // ---- Access levels --------------------------------------------------------- |
| 543 | // Tiers server-side: named member sets, each with its own key held only by |
| 544 | // its members (the shell does all crypto — this section is pure UI). |
| 545 | const levelsSec = section("Access levels"); |
| 546 | const levelsDesc = document.createElement("p"); |
| 547 | levelsDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px"; |
| 548 | levelsDesc.textContent = |
| 549 | "Named sets of members — Coaches, Family, Founders. A group can be limited to an access level: only its members can find and join it."; |
| 550 | levelsSec.appendChild(levelsDesc); |
| 551 | // The one constraint worth stating: gating a group means wrapping its key |
| 552 | // under the level's key, which only that level's members hold — so you |
| 553 | // must be in a level to make a group limited to it (an admin who curates |
| 554 | // a level without joining it can't read what it gates, by design). |
| 555 | const levelsNote = document.createElement("p"); |
| 556 | levelsNote.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 12px"; |
| 557 | levelsNote.textContent = |
| 558 | "To make a group limited to a level, you need to be a member of that level yourself."; |
| 559 | levelsSec.appendChild(levelsNote); |
| 560 | // Payments: whether members can be charged for a level here. When the |
| 561 | // platform supports it but this space hasn't linked a Stripe account, offer |
| 562 | // to link one; when it's linked, levels get a "Set a price" control (below). |
| 563 | // billingReady gates the per-level price UI in buildLevelBox. |
| 564 | let billingReady = false; |
| 565 | const paySec = document.createElement("div"); |
| 566 | paySec.style.cssText = "margin:0 0 12px"; |
| 567 | levelsSec.appendChild(paySec); |
| 568 | const refreshPay = async () => { |
| 569 | const st = await bridge.billingStatus(); |
| 570 | billingReady = !!(st && st.available); |
| 571 | paySec.textContent = ""; |
| 572 | if (!st || !st.platform) return; // payments not offered on this box — say nothing |
| 573 | const row = document.createElement("div"); |
| 574 | row.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:13px;color:var(--muted)"; |
| 575 | const txt = document.createElement("span"); |
| 576 | txt.textContent = billingReady |
| 577 | ? `Paid access levels are on — members pay your Stripe account${st.account_id ? ` (${st.account_id})` : ""}; we take 5.5%.` |
| 578 | : "Charge for access levels: connect your Stripe account to start."; |
| 579 | row.appendChild(txt); |
| 580 | // Linking goes through Stripe's own consent page (OAuth) — never a |
| 581 | // pasted account id. No broker/OAuth on this platform → say so instead. |
| 582 | if (await bridge.billingOAuthReady()) { |
| 583 | const link = document.createElement("button"); |
| 584 | link.className = "linkish"; |
| 585 | link.textContent = billingReady ? "Change Stripe account" : "Connect Stripe account"; |
| 586 | link.onclick = async () => { |
| 587 | link.disabled = true; |
| 588 | const res = await bridge.startBillingConnect(); |
| 589 | if (!res || !res.ok) { |
| 590 | flash((res && res.error) || "Stripe connect isn't available right now"); |
| 591 | link.disabled = false; |
| 592 | } // on ok the whole tab is on its way to Stripe |
| 593 | }; |
| 594 | row.appendChild(link); |
| 595 | } else if (!billingReady) { |
| 596 | txt.textContent = "Charge for access levels: Stripe connection isn't configured on this platform yet."; |
| 597 | } |
| 598 | paySec.appendChild(row); |
| 599 | }; |
| 600 | // The OAuth return leg (completeOAuthReturn in the shell) links the account |
| 601 | // in the background and announces itself — repaint and surface the word. |
| 602 | if (billingHook) window.removeEventListener("lchat:billing-changed", billingHook); |
| 603 | billingHook = (e) => { |
| 604 | refreshPay(); |
| 605 | if (e.detail && e.detail.text) flash(e.detail.text); |
| 606 | }; |
| 607 | window.addEventListener("lchat:billing-changed", billingHook); |
| 608 | |
| 609 | const levelsList = document.createElement("div"); |
| 610 | levelsSec.appendChild(levelsList); |
| 611 | const levelAddRow = document.createElement("div"); |
| 612 | levelAddRow.className = "admin-row"; |
| 613 | const levelName = document.createElement("input"); |
| 614 | levelName.type = "text"; |
| 615 | levelName.maxLength = 60; |
| 616 | levelName.placeholder = "New level name — e.g. Coach, VIP, MVP"; |
| 617 | levelName.setAttribute("aria-label", "New access level name"); |
| 618 | const levelAddBtn = document.createElement("button"); |
| 619 | levelAddBtn.textContent = "Add level"; |
| 620 | levelName.addEventListener("keydown", (e) => { |
| 621 | // The row isn't a <form> (the app builds plain DOM), so give the input |
| 622 | // the form behavior people expect: Return creates the level. |
| 623 | if (e.key === "Enter") { |
| 624 | e.preventDefault(); |
| 625 | levelAddBtn.click(); |
| 626 | } |
| 627 | }); |
| 628 | levelAddRow.append(levelName, levelAddBtn); |
| 629 | levelsSec.appendChild(levelAddRow); |
| 630 | const levelsStatus = statusLine(levelsSec); |
| 631 | |
| 632 | // In-place list management — add appends, delete removes, saving members |
| 633 | // swaps one box. A full repaint (initial load only) rebuilds everything; |
| 634 | // doing that after every action flashed the whole section blank. |
| 635 | let people = bridge.levelMembers(); |
| 636 | const emptyNote = document.createElement("p"); |
| 637 | emptyNote.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px"; |
| 638 | emptyNote.textContent = "No access levels yet."; |
| 639 | const syncEmptyNote = () => { |
| 640 | if (!levelsList.querySelector("[data-level]")) levelsList.appendChild(emptyNote); |
| 641 | else emptyNote.remove(); |
| 642 | }; |
| 643 | |
| 644 | const buildLevelBox = (level) => { |
| 645 | const box = document.createElement("div"); |
| 646 | box.className = "admin-row"; |
| 647 | box.dataset.level = level.id; |
| 648 | box.style.cssText = "display:block;padding:10px 0"; |
| 649 | const head = document.createElement("div"); |
| 650 | // wrap + a shrinkable name: a long level name used to make this row |
| 651 | // unshrinkable, which forced the whole page wider than a phone pane. |
| 652 | head.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap"; |
| 653 | const name = document.createElement("strong"); |
| 654 | name.style.cssText = "min-width:0;overflow-wrap:anywhere"; |
| 655 | name.textContent = level.name; |
| 656 | const count = document.createElement("span"); |
| 657 | count.style.cssText = "font-size:13px;color:var(--muted)"; |
| 658 | const n = (level.members || []).length; |
| 659 | count.textContent = n === 1 ? "1 member" : `${n} members`; |
| 660 | const spacer = document.createElement("span"); |
| 661 | spacer.style.flex = "1"; |
| 662 | const editBtn = document.createElement("button"); |
| 663 | editBtn.className = "linkish"; |
| 664 | editBtn.textContent = "Members"; |
| 665 | editBtn.setAttribute("aria-expanded", "false"); |
| 666 | const delBtn = document.createElement("button"); |
| 667 | delBtn.className = "linkish danger"; |
| 668 | delBtn.textContent = "Delete"; |
| 669 | head.append(name, count, spacer, editBtn, delBtn); |
| 670 | box.appendChild(head); |
| 671 | |
| 672 | // Who can see who's in this level. "Members choose" hands each member |
| 673 | // the toggle in their own profile. All three hide the roster only from |
| 674 | // NON-members — a level's members share its key, so they always know |
| 675 | // each other. |
| 676 | const visRow = document.createElement("div"); |
| 677 | visRow.style.cssText = "display:flex;align-items:center;gap:8px;margin-top:6px"; |
| 678 | const visLabel = document.createElement("span"); |
| 679 | visLabel.style.cssText = "font-size:12px;color:var(--muted)"; |
| 680 | visLabel.textContent = "Shown on profiles:"; |
| 681 | const visSel = document.createElement("select"); |
| 682 | visSel.setAttribute("aria-label", `Who sees who's in ${level.name}`); |
| 683 | for (const [val, text] of [ |
| 684 | ["shown", "Everyone"], |
| 685 | ["hidden", "Only its members"], |
| 686 | ["user", "Members choose"], |
| 687 | ]) { |
| 688 | const o = document.createElement("option"); |
| 689 | o.value = val; |
| 690 | o.textContent = text; |
| 691 | visSel.appendChild(o); |
| 692 | } |
| 693 | visSel.value = level.visibility || "shown"; |
| 694 | visSel.onchange = async () => { |
| 695 | const prev = level.visibility || "shown"; |
| 696 | const want = visSel.value; |
| 697 | visSel.disabled = true; |
| 698 | const ok = await bridge.setTierPolicy(level.id, want); |
| 699 | visSel.disabled = false; |
| 700 | if (ok) { |
| 701 | level.visibility = want; |
| 702 | flash(levelsStatus, "Saved."); |
| 703 | } else { |
| 704 | visSel.value = prev; |
| 705 | flash(levelsStatus, "Couldn't save — try again."); |
| 706 | } |
| 707 | }; |
| 708 | visRow.append(visLabel, visSel); |
| 709 | box.appendChild(visRow); |
| 710 | |
| 711 | // Price: make a level sellable so members can subscribe. Only shown once |
| 712 | // payments are set up for the space (billingReady); otherwise a level is |
| 713 | // admin/invite-assigned only. Members pay the admin's Stripe account; the |
| 714 | // platform takes a 5.5% fee. |
| 715 | if (billingReady) { |
| 716 | const priceRow = document.createElement("div"); |
| 717 | priceRow.style.cssText = "display:flex;align-items:center;gap:8px;margin-top:6px;flex-wrap:wrap"; |
| 718 | const priceLabel = document.createElement("span"); |
| 719 | priceLabel.style.cssText = "font-size:12px;color:var(--muted)"; |
| 720 | priceLabel.textContent = "Members can subscribe:"; |
| 721 | const cur = level.price_cents |
| 722 | ? `${(level.price_cents / 100).toFixed(level.price_cents % 100 ? 2 : 0)} ${(level.currency || "").toUpperCase()} / ${level.interval || "month"}` |
| 723 | : "Not for sale"; |
| 724 | const priceVal = document.createElement("strong"); |
| 725 | priceVal.style.fontSize = "13px"; |
| 726 | priceVal.textContent = cur; |
| 727 | const setBtn = document.createElement("button"); |
| 728 | setBtn.className = "linkish"; |
| 729 | setBtn.textContent = level.price_cents ? "Change" : "Set a price"; |
| 730 | setBtn.onclick = () => editLevelPrice(level, () => paintLevels()); |
| 731 | priceRow.append(priceLabel, priceVal, setBtn); |
| 732 | if (level.price_cents) { |
| 733 | const clr = document.createElement("button"); |
| 734 | clr.className = "linkish danger"; |
| 735 | clr.textContent = "Stop selling"; |
| 736 | clr.onclick = async () => { |
| 737 | clr.disabled = true; |
| 738 | const ok = await bridge.setTierPrice(level.id, { cents: 0 }); |
| 739 | flash(levelsStatus, ok ? "No longer for sale." : "Couldn't update — try again."); |
| 740 | await paintLevels(); |
| 741 | }; |
| 742 | priceRow.appendChild(clr); |
| 743 | } |
| 744 | box.appendChild(priceRow); |
| 745 | } |
| 746 | |
| 747 | if (level.key_lost) { |
| 748 | // Minted once, every holder gone — unrecoverable by design (keys |
| 749 | // never re-mint under groups wrapped to the old one). Say so. |
| 750 | const lost = document.createElement("div"); |
| 751 | lost.style.cssText = "font-size:12px;color:var(--danger);margin-top:4px"; |
| 752 | lost.textContent = |
| 753 | "This level's key was lost when its last member left. Delete it and create a new one."; |
| 754 | box.appendChild(lost); |
| 755 | } |
| 756 | // Members whose key copy hasn't landed yet: honest inline status, the |
| 757 | // same "on its way" register as photo syncing (never a toast). |
| 758 | const missing = (level.members || []).filter((uid) => !(level.have || []).includes(uid)); |
| 759 | if (!level.key_lost && missing.length) { |
| 760 | const wait = document.createElement("div"); |
| 761 | wait.style.cssText = "font-size:12px;color:var(--muted);margin-top:4px"; |
| 762 | wait.textContent = |
| 763 | missing.length === 1 |
| 764 | ? "1 member's key is on its way — it arrives when a key holder is online." |
| 765 | : `${missing.length} members' keys are on their way — they arrive when a key holder is online.`; |
| 766 | box.appendChild(wait); |
| 767 | } |
| 768 | |
| 769 | // Refresh just this box from the server's rows (post-save): the box |
| 770 | // swaps in place, the rest of the list never repaints. |
| 771 | const refreshBox = async () => { |
| 772 | const fresh = (await bridge.tiers()).find((t) => t.id === level.id); |
| 773 | if (fresh) box.replaceWith(buildLevelBox(fresh)); |
| 774 | }; |
| 775 | |
| 776 | let editor = null; |
| 777 | editBtn.onclick = () => { |
| 778 | if (editor) { |
| 779 | editor.remove(); |
| 780 | editor = null; |
| 781 | editBtn.setAttribute("aria-expanded", "false"); |
| 782 | return; |
| 783 | } |
| 784 | editBtn.setAttribute("aria-expanded", "true"); |
| 785 | people = bridge.levelMembers(); // fresh roster snapshot for the picker |
| 786 | editor = document.createElement("div"); |
| 787 | editor.style.cssText = "margin:8px 0 4px;padding-left:4px"; |
| 788 | const selected = new Set(level.members || []); |
| 789 | for (const p of people) { |
| 790 | const row = document.createElement("label"); |
| 791 | row.style.cssText = "display:flex;align-items:center;gap:8px;padding:4px 0;cursor:pointer"; |
| 792 | const cb = document.createElement("input"); |
| 793 | cb.type = "checkbox"; |
| 794 | // Escape the app-wide `input { width:100% }` rule, which would make the |
| 795 | // checkbox a full-width flex item and shove the name to the far edge. |
| 796 | cb.style.cssText = "width:18px;height:18px;flex:none;margin:0"; |
| 797 | cb.checked = selected.has(p.user_id); |
| 798 | cb.onchange = () => (cb.checked ? selected.add(p.user_id) : selected.delete(p.user_id)); |
| 799 | const l = document.createElement("span"); |
| 800 | l.textContent = p.label; |
| 801 | row.append(cb, l); |
| 802 | editor.appendChild(row); |
| 803 | } |
| 804 | const foot = document.createElement("div"); |
| 805 | foot.style.cssText = "display:flex;gap:8px;margin-top:6px"; |
| 806 | const save = document.createElement("button"); |
| 807 | save.textContent = "Save members"; |
| 808 | save.onclick = async () => { |
| 809 | save.disabled = true; |
| 810 | flash(levelsStatus, "Saving…"); |
| 811 | const ok = await bridge.setTierMembers(level.id, [...selected]); |
| 812 | flash(levelsStatus, ok ? "Saved." : "Couldn't save — try again."); |
| 813 | await refreshBox(); |
| 814 | }; |
| 815 | const cancel = document.createElement("button"); |
| 816 | cancel.className = "secondary"; |
| 817 | cancel.textContent = "Cancel"; |
| 818 | cancel.onclick = () => { |
| 819 | editor.remove(); |
| 820 | editor = null; |
| 821 | editBtn.setAttribute("aria-expanded", "false"); |
| 822 | }; |
| 823 | foot.append(save, cancel); |
| 824 | editor.appendChild(foot); |
| 825 | box.appendChild(editor); |
| 826 | }; |
| 827 | delBtn.onclick = async () => { |
| 828 | if (!confirm(`Delete the "${level.name}" access level?`)) return; |
| 829 | delBtn.disabled = true; |
| 830 | const status = await bridge.deleteTier(level.id); |
| 831 | if (status === 200) { |
| 832 | box.remove(); |
| 833 | syncEmptyNote(); |
| 834 | flash(levelsStatus, "Deleted."); |
| 835 | return; |
| 836 | } |
| 837 | delBtn.disabled = false; |
| 838 | flash( |
| 839 | levelsStatus, |
| 840 | status === 409 ? "Can't delete — groups are still limited to this level." : "Couldn't delete — try again.", |
| 841 | ); |
| 842 | }; |
| 843 | return box; |
| 844 | }; |
| 845 | |
| 846 | // A minimal admin modal (its own backdrop, like showInviteModal) for the |
| 847 | // two billing config flows. onSave(value) returns truthy to close. |
| 848 | const configModal = (title, fields, onSave) => { |
| 849 | const backdrop = document.createElement("div"); |
| 850 | backdrop.style.cssText = |
| 851 | "position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;padding:20px"; |
| 852 | const cardEl = document.createElement("div"); |
| 853 | cardEl.style.cssText = "background:var(--panel);border-radius:14px;padding:22px;max-width:340px;width:100%;box-shadow:0 10px 40px rgba(0,0,0,0.3)"; |
| 854 | const hh = document.createElement("h3"); |
| 855 | hh.textContent = title; |
| 856 | hh.style.cssText = "margin:0 0 14px;font-size:17px;font-weight:700"; |
| 857 | cardEl.appendChild(hh); |
| 858 | const inputs = {}; |
| 859 | for (const f of fields) { |
| 860 | const lab = document.createElement("label"); |
| 861 | lab.className = "field-label"; |
| 862 | lab.textContent = f.label; |
| 863 | cardEl.appendChild(lab); |
| 864 | let el; |
| 865 | if (f.options) { |
| 866 | el = document.createElement("select"); |
| 867 | for (const o of f.options) { |
| 868 | const opt = document.createElement("option"); |
| 869 | opt.value = o.value; |
| 870 | opt.textContent = o.label; |
| 871 | el.appendChild(opt); |
| 872 | } |
| 873 | } else { |
| 874 | el = document.createElement("input"); |
| 875 | el.type = f.type || "text"; |
| 876 | if (f.placeholder) el.placeholder = f.placeholder; |
| 877 | } |
| 878 | if (f.value != null) el.value = f.value; |
| 879 | el.style.cssText = "width:100%;margin-bottom:10px"; |
| 880 | cardEl.appendChild(el); |
| 881 | inputs[f.key] = el; |
| 882 | } |
| 883 | const st = statusLine(cardEl); |
| 884 | const foot = document.createElement("div"); |
| 885 | foot.style.cssText = "display:flex;gap:8px;justify-content:flex-end;margin-top:8px"; |
| 886 | const cancel = document.createElement("button"); |
| 887 | cancel.className = "secondary"; |
| 888 | cancel.textContent = "Cancel"; |
| 889 | cancel.onclick = () => backdrop.remove(); |
| 890 | const save = document.createElement("button"); |
| 891 | save.textContent = "Save"; |
| 892 | save.onclick = async () => { |
| 893 | save.disabled = true; |
| 894 | const vals = {}; |
| 895 | for (const k of Object.keys(inputs)) vals[k] = inputs[k].value.trim(); |
| 896 | const ok = await onSave(vals); |
| 897 | if (ok) backdrop.remove(); |
| 898 | else { |
| 899 | save.disabled = false; |
| 900 | flash(st, "Couldn't save — check the values and try again."); |
| 901 | } |
| 902 | }; |
| 903 | foot.append(cancel, save); |
| 904 | cardEl.appendChild(foot); |
| 905 | backdrop.appendChild(cardEl); |
| 906 | backdrop.addEventListener("click", (e) => e.target === backdrop && backdrop.remove()); |
| 907 | document.body.appendChild(backdrop); |
| 908 | const first = cardEl.querySelector("input,select"); |
| 909 | if (first) first.focus(); |
| 910 | }; |
| 911 | |
| 912 | const editLevelPrice = (level, done) => |
| 913 | configModal( |
| 914 | `Price for ${level.name}`, |
| 915 | [ |
| 916 | { key: "amount", label: "Amount", type: "number", value: level.price_cents ? level.price_cents / 100 : "" }, |
| 917 | { |
| 918 | key: "currency", |
| 919 | label: "Currency", |
| 920 | options: [ |
| 921 | { value: "eur", label: "EUR (€)" }, |
| 922 | { value: "usd", label: "USD ($)" }, |
| 923 | { value: "gbp", label: "GBP (£)" }, |
| 924 | ], |
| 925 | }, |
| 926 | { |
| 927 | key: "interval", |
| 928 | label: "Billed", |
| 929 | options: [ |
| 930 | { value: "month", label: "Monthly" }, |
| 931 | { value: "year", label: "Yearly" }, |
| 932 | ], |
| 933 | }, |
| 934 | ], |
| 935 | async (v) => { |
| 936 | const cents = Math.round(parseFloat(v.amount) * 100); |
| 937 | if (!(cents > 0)) return false; |
| 938 | const ok = await bridge.setTierPrice(level.id, { cents, currency: v.currency, interval: v.interval }); |
| 939 | if (ok) await done(); |
| 940 | return ok; |
| 941 | }, |
| 942 | ); |
| 943 | |
| 944 | const paintLevels = async () => { |
| 945 | await refreshPay(); |
| 946 | const levels = await bridge.tiers(); |
| 947 | people = bridge.levelMembers(); |
| 948 | levelsList.textContent = ""; |
| 949 | for (const level of levels) levelsList.appendChild(buildLevelBox(level)); |
| 950 | syncEmptyNote(); |
| 951 | }; |
| 952 | levelAddBtn.onclick = async () => { |
| 953 | const name = levelName.value.trim(); |
| 954 | if (!name) return; |
| 955 | levelAddBtn.disabled = true; |
| 956 | const id = await bridge.createTier(name); |
| 957 | levelAddBtn.disabled = false; |
| 958 | if (!id) { |
| 959 | flash(levelsStatus, "Couldn't create — try again."); |
| 960 | return; |
| 961 | } |
| 962 | levelName.value = ""; |
| 963 | flash(levelsStatus, `"${name}" created — now add its members.`); |
| 964 | // Appears in place — a full repaint here flashed the whole section. |
| 965 | levelsList.appendChild(buildLevelBox({ id, name, members: [], have: [] })); |
| 966 | syncEmptyNote(); |
| 967 | }; |
| 968 | paintLevels(); |
| 969 | |
| 970 | // ---- Reports (issue #578) -------------------------------------------------- |
| 971 | // The moderation inbox. Reports are testimony: the row shows who says what |
| 972 | // about whom (decrypted from the blob sealed to THIS admin) plus the |
| 973 | // repeat signals; the actions are the ladder — dismiss, warn, remove from |
| 974 | // the group, remove an access level, remove from the space. |
| 975 | const repSec = section("Reports"); |
| 976 | const repDesc = document.createElement("p"); |
| 977 | repDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px"; |
| 978 | repDesc.textContent = |
| 979 | "What members flag lands here. Reports can't be verified automatically — they're one side of a story, so weigh the patterns: how often the reporter files, how often the person is named. You only see conversations you're in; members surface the rest."; |
| 980 | repSec.appendChild(repDesc); |
| 981 | const repList = document.createElement("div"); |
| 982 | repSec.appendChild(repList); |
| 983 | const repStatus = statusLine(repSec); |
| 984 | |
| 985 | const fmtWhen = (ts) => new Date(ts * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }); |
| 986 | |
| 987 | // Queue actions append audit entries, so resolving a report repaints the |
| 988 | // Audit section too (bound once that section exists below). |
| 989 | let repaintAudit = () => {}; |
| 990 | |
| 991 | const buildReportBox = (rep, refresh) => { |
| 992 | const box = document.createElement("div"); |
| 993 | box.className = "admin-report" + (rep.status === "open" ? "" : " resolved"); |
| 994 | const head = document.createElement("div"); |
| 995 | head.className = "admin-report-head"; |
| 996 | const title = document.createElement("strong"); |
| 997 | const cat = rep.content && rep.content.category ? rep.content.category : "report"; |
| 998 | title.textContent = `${rep.reported_name} — ${cat}`; |
| 999 | head.appendChild(title); |
| 1000 | const when = document.createElement("span"); |
| 1001 | when.className = "admin-report-when"; |
| 1002 | when.textContent = fmtWhen(rep.created_at); |
| 1003 | head.appendChild(when); |
| 1004 | box.appendChild(head); |
| 1005 | |
| 1006 | const byline = document.createElement("div"); |
| 1007 | byline.className = "admin-report-byline"; |
| 1008 | const repeat = |
| 1009 | (rep.reported_count > 1 ? ` · named in ${rep.reported_count} reports` : "") + |
| 1010 | (rep.reporter_count > 1 ? ` · reporter has filed ${rep.reporter_count}` : ""); |
| 1011 | // Invite hygiene (issue #578): how the reported member got in. |
| 1012 | const lineage = rep.invited_by_name ? ` · invited by ${rep.invited_by_name}` : ""; |
| 1013 | byline.textContent = |
| 1014 | `Reported by ${rep.reporter_name}` + (rep.thread_name ? ` in ${rep.thread_name}` : "") + repeat + lineage; |
| 1015 | box.appendChild(byline); |
| 1016 | |
| 1017 | if (rep.content) { |
| 1018 | if (rep.content.text) { |
| 1019 | const words = document.createElement("p"); |
| 1020 | words.className = "admin-report-words"; |
| 1021 | words.textContent = rep.content.text; |
| 1022 | box.appendChild(words); |
| 1023 | } |
| 1024 | for (const ev of rep.content.evidence || []) { |
| 1025 | const q = document.createElement("blockquote"); |
| 1026 | q.className = "report-quote"; |
| 1027 | const qwho = document.createElement("strong"); |
| 1028 | qwho.textContent = (ev.name || rep.reported_name) + ": "; |
| 1029 | q.appendChild(qwho); |
| 1030 | q.appendChild(document.createTextNode(ev.text || (ev.attachments || []).join(", "))); |
| 1031 | box.appendChild(q); |
| 1032 | } |
| 1033 | } else if (rep.status === "open") { |
| 1034 | const na = document.createElement("p"); |
| 1035 | na.className = "admin-report-words"; |
| 1036 | na.style.fontStyle = "italic"; |
| 1037 | // Missing content has more than one possible cause (filed before this |
| 1038 | // admin existed, or the reporter's tab had a stale roster at filing |
| 1039 | // time and silently omitted them) and the server has no reliable way |
| 1040 | // to tell them apart after the fact — so this stays honest rather |
| 1041 | // than asserting a specific one. |
| 1042 | na.textContent = "Not readable — its contents weren't sealed to you at filing time."; |
| 1043 | box.appendChild(na); |
| 1044 | } |
| 1045 | |
| 1046 | if (rep.status !== "open") { |
| 1047 | const done = document.createElement("div"); |
| 1048 | done.className = "admin-report-byline"; |
| 1049 | done.textContent = `Resolved (${rep.action || "handled"})${rep.resolved_name ? ` by ${rep.resolved_name}` : ""}`; |
| 1050 | box.appendChild(done); |
| 1051 | return box; |
| 1052 | } |
| 1053 | |
| 1054 | // The action ladder. Each performs its act, then closes the report with |
| 1055 | // the matching outcome so the queue and audit log tell one story. |
| 1056 | const acts = document.createElement("div"); |
| 1057 | acts.className = "admin-report-actions"; |
| 1058 | // run() resolves to: true — the deed is done, repaint the queue; |
| 1059 | // "keep" — nothing resolved (a cancelled confirm, or an expander now |
| 1060 | // waiting for input), leave the row exactly as it is; false — failure. |
| 1061 | const actBtn = (label, danger, run) => { |
| 1062 | const b = document.createElement("button"); |
| 1063 | b.className = danger ? "danger" : "secondary"; |
| 1064 | b.textContent = label; |
| 1065 | b.onclick = async () => { |
| 1066 | for (const x of acts.querySelectorAll("button")) x.disabled = true; |
| 1067 | const ok = await run(); |
| 1068 | if (ok === "keep") { |
| 1069 | for (const x of acts.querySelectorAll("button")) x.disabled = false; |
| 1070 | return; |
| 1071 | } |
| 1072 | if (!ok) { |
| 1073 | for (const x of acts.querySelectorAll("button")) x.disabled = false; |
| 1074 | flash(repStatus, "Couldn't do that — try again."); |
| 1075 | return; |
| 1076 | } |
| 1077 | refresh(); |
| 1078 | }; |
| 1079 | acts.appendChild(b); |
| 1080 | return b; |
| 1081 | }; |
| 1082 | // Inline expander under the buttons — details render in place (never a |
| 1083 | // floating dialog over content someone's reading). |
| 1084 | const detail = document.createElement("div"); |
| 1085 | detail.className = "admin-report-detail"; |
| 1086 | const openDetail = (build) => { |
| 1087 | detail.textContent = ""; |
| 1088 | build(detail); |
| 1089 | }; |
| 1090 | actBtn("Dismiss", false, () => bridge.resolveReport(rep.id, "dismissed")); |
| 1091 | actBtn("Warn…", false, async () => { |
| 1092 | openDetail((d) => { |
| 1093 | const ta = document.createElement("textarea"); |
| 1094 | ta.rows = 3; |
| 1095 | ta.style.width = "100%"; |
| 1096 | ta.value = |
| 1097 | "A report was filed about your behaviour here. Please keep this space welcoming — continued reports can mean removal."; |
| 1098 | ta.setAttribute("aria-label", `Warning to ${rep.reported_name}`); |
| 1099 | fitHeight(ta); |
| 1100 | const send = document.createElement("button"); |
| 1101 | send.textContent = `Send warning to ${rep.reported_name}`; |
| 1102 | send.onclick = async () => { |
| 1103 | send.disabled = true; |
| 1104 | if (!(await bridge.warnUser(rep.reported_id, ta.value.trim(), rep.id)) || |
| 1105 | !(await bridge.resolveReport(rep.id, "warned"))) { |
| 1106 | send.disabled = false; |
| 1107 | flash(repStatus, "Couldn't send the warning — try again."); |
| 1108 | return; |
| 1109 | } |
| 1110 | refresh(); |
| 1111 | }; |
| 1112 | d.append(ta, send); |
| 1113 | ta.focus(); |
| 1114 | }); |
| 1115 | return "keep"; // the expander takes it from here; report stays open |
| 1116 | }); |
| 1117 | if (rep.thread_id && !rep.reported_gone) { |
| 1118 | actBtn("Remove from group", true, async () => { |
| 1119 | if (!confirm(`Remove ${rep.reported_name} from ${rep.thread_name || "that group"}?`)) return "keep"; |
| 1120 | if (!(await bridge.removeFromThread(rep.thread_id, rep.reported_id))) return false; |
| 1121 | return bridge.resolveReport(rep.id, "removed_thread"); |
| 1122 | }); |
| 1123 | } |
| 1124 | if (!rep.reported_gone) { |
| 1125 | // The demote rung, only when they hold a level to lose (tiers tie-in). |
| 1126 | bridge.memberLevels(rep.reported_id).then((levels) => { |
| 1127 | if (!levels || !levels.length) return; |
| 1128 | actBtn(`Remove access level…`, true, async () => { |
| 1129 | openDetail((d) => { |
| 1130 | const sel = document.createElement("select"); |
| 1131 | sel.setAttribute("aria-label", "Access level to remove"); |
| 1132 | for (const l of levels) { |
| 1133 | const o = document.createElement("option"); |
| 1134 | o.value = l.id; |
| 1135 | o.textContent = l.name; |
| 1136 | sel.appendChild(o); |
| 1137 | } |
| 1138 | const go = document.createElement("button"); |
| 1139 | go.className = "danger"; |
| 1140 | go.textContent = `Remove level from ${rep.reported_name}`; |
| 1141 | go.onclick = async () => { |
| 1142 | go.disabled = true; |
| 1143 | if (!(await bridge.removeFromLevel(sel.value, rep.reported_id)) || |
| 1144 | !(await bridge.resolveReport(rep.id, "demoted"))) { |
| 1145 | go.disabled = false; |
| 1146 | flash(repStatus, "Couldn't remove the level — try again."); |
| 1147 | return; |
| 1148 | } |
| 1149 | refresh(); |
| 1150 | }; |
| 1151 | d.append(sel, go); |
| 1152 | }); |
| 1153 | return "keep"; // expander flow |
| 1154 | }); |
| 1155 | }); |
| 1156 | actBtn("Remove from space", true, async () => { |
| 1157 | if ( |
| 1158 | !confirm( |
| 1159 | `Remove ${rep.reported_name} from this space? Their messages and access are deleted. This can't be undone.`, |
| 1160 | ) |
| 1161 | ) |
| 1162 | return "keep"; |
| 1163 | if (!(await bridge.removeFromSpace(rep.reported_id))) return false; |
| 1164 | return bridge.resolveReport(rep.id, "removed_space"); |
| 1165 | }); |
| 1166 | // The top rung: removal that stays removed — their key can't come |
| 1167 | // back through any invite (a fresh identity still could; pausing |
| 1168 | // member invites is the companion move during a spam wave). |
| 1169 | actBtn("Remove & ban", true, async () => { |
| 1170 | if ( |
| 1171 | !confirm( |
| 1172 | `Ban ${rep.reported_name}? They're removed from the space and can't rejoin with any invite. This can't be undone.`, |
| 1173 | ) |
| 1174 | ) |
| 1175 | return "keep"; |
| 1176 | if (!(await bridge.banFromSpace(rep.reported_id))) return false; |
| 1177 | return bridge.resolveReport(rep.id, "banned"); |
| 1178 | }); |
| 1179 | } |
| 1180 | box.appendChild(acts); |
| 1181 | box.appendChild(detail); |
| 1182 | return box; |
| 1183 | }; |
| 1184 | |
| 1185 | const paintReports = async () => { |
| 1186 | const reports = await bridge.reports(); |
| 1187 | repList.textContent = ""; |
| 1188 | if (reports === null) { |
| 1189 | flash(repStatus, "Couldn't load reports."); |
| 1190 | return; |
| 1191 | } |
| 1192 | if (!reports.length) { |
| 1193 | const empty = document.createElement("p"); |
| 1194 | empty.style.cssText = "font-size:13px;color:var(--muted);margin:0"; |
| 1195 | empty.textContent = "No reports. Quiet is good."; |
| 1196 | repList.appendChild(empty); |
| 1197 | return; |
| 1198 | } |
| 1199 | for (const rep of reports) |
| 1200 | repList.appendChild( |
| 1201 | buildReportBox(rep, () => { |
| 1202 | paintReports(); |
| 1203 | repaintAudit(); |
| 1204 | }), |
| 1205 | ); |
| 1206 | }; |
| 1207 | paintReports(); |
| 1208 | |
| 1209 | // ---- Audit log (issue #578) ------------------------------------------------- |
| 1210 | // Every moderation action, append-only: who did what to whom, when, off |
| 1211 | // which report. Actions only — any quoted words stay sealed to admin keys. |
| 1212 | const audSec = section("Audit log"); |
| 1213 | const audList = document.createElement("div"); |
| 1214 | audSec.appendChild(audList); |
| 1215 | const audMoreRow = document.createElement("div"); |
| 1216 | audSec.appendChild(audMoreRow); |
| 1217 | const AUD_VERBS = { |
| 1218 | "member.removed": "removed __S__ from the space", |
| 1219 | "member.warned": "warned __S__", |
| 1220 | "thread.removed": "removed __S__ from a group", |
| 1221 | "level.removed": "removed an access level from __S__", |
| 1222 | "report.resolved": "resolved a report", |
| 1223 | "member.banned": "banned __S__", |
| 1224 | "invites.paused": "paused member invites", |
| 1225 | "invites.resumed": "resumed member invites", |
| 1226 | }; |
| 1227 | let audBefore = 0; |
| 1228 | const paintAudit = async (append) => { |
| 1229 | const entries = await bridge.audit(append ? audBefore : 0); |
| 1230 | if (!append) audList.textContent = ""; |
| 1231 | if (entries === null) return; |
| 1232 | if (!entries.length && !append) { |
| 1233 | const empty = document.createElement("p"); |
| 1234 | empty.style.cssText = "font-size:13px;color:var(--muted);margin:0"; |
| 1235 | empty.textContent = "Nothing yet — moderation actions will be recorded here."; |
| 1236 | audList.appendChild(empty); |
| 1237 | audMoreRow.textContent = ""; |
| 1238 | return; |
| 1239 | } |
| 1240 | for (const en of entries) { |
| 1241 | const row = document.createElement("div"); |
| 1242 | row.className = "admin-audit-row"; |
| 1243 | const what = (AUD_VERBS[en.action] || en.action).replace("__S__", en.subject_name || "a member"); |
| 1244 | const line = document.createElement("span"); |
| 1245 | line.textContent = `${en.actor_name} ${what}` + (en.detail && en.action === "report.resolved" ? ` (${en.detail})` : ""); |
| 1246 | const when = document.createElement("span"); |
| 1247 | when.className = "admin-report-when"; |
| 1248 | when.textContent = fmtWhen(en.ts); |
| 1249 | row.append(line, when); |
| 1250 | audList.appendChild(row); |
| 1251 | audBefore = en.id; |
| 1252 | } |
| 1253 | audMoreRow.textContent = ""; |
| 1254 | if (entries.length >= 100) { |
| 1255 | const more = document.createElement("button"); |
| 1256 | more.className = "secondary"; |
| 1257 | more.textContent = "Show older"; |
| 1258 | more.onclick = () => paintAudit(true); |
| 1259 | audMoreRow.appendChild(more); |
| 1260 | } |
| 1261 | }; |
| 1262 | paintAudit(false); |
| 1263 | repaintAudit = () => paintAudit(false); |
| 1264 | |
| 1265 | // ---- Updates --------------------------------------------------------------- |
| 1266 | const updSec = section("Updates"); |
| 1267 | const updDesc = document.createElement("p"); |
| 1268 | updDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 12px"; |
| 1269 | updDesc.textContent = |
| 1270 | "The server can keep itself up to date. Stable is well tested; Beta gets features a little earlier; Edge is the bleeding edge. Updates install in seconds and never touch your data."; |
| 1271 | updSec.appendChild(updDesc); |
| 1272 | const updInfo = document.createElement("p"); |
| 1273 | updInfo.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 12px"; |
| 1274 | updSec.appendChild(updInfo); |
| 1275 | const autoCheck = switchRow(updSec, "Install updates automatically"); |
| 1276 | const dlCheck = switchRow(updSec, "Download new versions automatically"); |
| 1277 | const chanRow = document.createElement("div"); |
| 1278 | chanRow.className = N.cls("row"); |
| 1279 | const chanLabel = document.createElement("label"); |
| 1280 | chanLabel.textContent = "Channel"; |
| 1281 | const chanSelect = document.createElement("select"); |
| 1282 | for (const [value, text] of [ |
| 1283 | ["stable", "Stable — well tested (recommended)"], |
| 1284 | ["beta", "Beta — near-stable"], |
| 1285 | ["edge", "Edge — the bleeding edge"], |
| 1286 | ]) { |
| 1287 | const o = document.createElement("option"); |
| 1288 | o.value = value; |
| 1289 | o.textContent = text; |
| 1290 | chanSelect.appendChild(o); |
| 1291 | } |
| 1292 | chanRow.append(chanLabel, chanSelect); |
| 1293 | updSec.appendChild(chanRow); |
| 1294 | // The major pin (update_major): follow the channel for minors/patches, |
| 1295 | // wait at a major-version boundary until the admin raises this. |
| 1296 | const majorRow = document.createElement("div"); |
| 1297 | majorRow.className = "admin-row"; |
| 1298 | const majorLabel = document.createElement("label"); |
| 1299 | majorLabel.textContent = "Stay on major version"; |
| 1300 | majorLabel.htmlFor = "adm-update-major"; |
| 1301 | const majorInput = document.createElement("input"); |
| 1302 | majorInput.type = "text"; |
| 1303 | majorInput.id = "adm-update-major"; |
| 1304 | majorInput.inputMode = "numeric"; |
| 1305 | majorInput.placeholder = "any"; |
| 1306 | majorInput.style.width = "72px"; |
| 1307 | majorRow.append(majorLabel, majorInput); |
| 1308 | updSec.appendChild(majorRow); |
| 1309 | const majorHint = document.createElement("p"); |
| 1310 | majorHint.style.cssText = "font-size:12px;color:var(--muted);margin:4px 0 12px"; |
| 1311 | majorHint.textContent = |
| 1312 | "Leave empty to follow every release. With a number set, small updates keep arriving but a release crossing that major version waits for your say-so."; |
| 1313 | updSec.appendChild(majorHint); |
| 1314 | const updStatus = statusLine(updSec); |
| 1315 | |
| 1316 | const paintUpdateInfo = (update, channel, pin) => { |
| 1317 | const u = update || {}; |
| 1318 | let line = `Running build ${u.build || "unknown"}`; |
| 1319 | if (pin) { |
| 1320 | line += ` — pinned to ${pin}; automatic updates are paused (pick a channel to resume)`; |
| 1321 | } else if (u.available_version && u.available_version !== u.build) { |
| 1322 | const label = u.available_semver ? `v${u.available_semver} (${u.available_version})` : u.available_version; |
| 1323 | line += ` — ${label} is available on ${channel}`; |
| 1324 | if (u.major_held) { |
| 1325 | line += ` (held: it crosses the major version this space stays on)`; |
| 1326 | } else if (u.bake_hours) { |
| 1327 | // The supply-chain safety hold: new releases wait before auto-installing. |
| 1328 | line += ` (auto-installs after a ${u.bake_hours}-hour safety hold)`; |
| 1329 | } |
| 1330 | } else if (u.checked_at) { |
| 1331 | line += ` — up to date on ${channel}`; |
| 1332 | } else { |
| 1333 | line += " — hasn't checked for updates yet"; |
| 1334 | } |
| 1335 | updInfo.textContent = line + "."; |
| 1336 | }; |
| 1337 | |
| 1338 | // A pin shows as an extra selected option in the channel picker; choosing |
| 1339 | // a real channel clears it (one combined write) and the option disappears. |
| 1340 | const PINNED = "__pinned__"; |
| 1341 | let pinnedOpt = null; |
| 1342 | const showPinned = (pin) => { |
| 1343 | if (pinnedOpt) { |
| 1344 | pinnedOpt.remove(); |
| 1345 | pinnedOpt = null; |
| 1346 | } |
| 1347 | if (pin) { |
| 1348 | pinnedOpt = document.createElement("option"); |
| 1349 | pinnedOpt.value = PINNED; |
| 1350 | pinnedOpt.textContent = `Pinned to ${pin}`; |
| 1351 | chanSelect.appendChild(pinnedOpt); |
| 1352 | chanSelect.value = PINNED; |
| 1353 | } |
| 1354 | }; |
| 1355 | |
| 1356 | const updState = { update: {}, channel: "stable", pin: "", major: "" }; |
| 1357 | const saveUpdatePref = async (patch, revert, saved) => { |
| 1358 | if (await bridge.setUpdatePrefs(patch)) { |
| 1359 | flash(updStatus, saved); |
| 1360 | return true; |
| 1361 | } |
| 1362 | revert(); |
| 1363 | flash(updStatus, "Couldn't save — try again."); |
| 1364 | return false; |
| 1365 | }; |
| 1366 | majorInput.onchange = async () => { |
| 1367 | const v = majorInput.value.trim(); |
| 1368 | if (v !== "" && !/^\d{1,4}$/.test(v)) { |
| 1369 | flash(updStatus, "Major version must be a number — or empty to follow everything."); |
| 1370 | majorInput.value = updState.major; |
| 1371 | return; |
| 1372 | } |
| 1373 | const prev = updState.major; |
| 1374 | const ok = await saveUpdatePref( |
| 1375 | { update_major: v }, |
| 1376 | () => (majorInput.value = prev), |
| 1377 | v ? `Staying on major version ${v} — bigger jumps wait for you.` : "Following every release.", |
| 1378 | ); |
| 1379 | if (ok) { |
| 1380 | updState.major = v; |
| 1381 | majorInput.value = v; |
| 1382 | } |
| 1383 | }; |
| 1384 | autoCheck.onchange = () => |
| 1385 | saveUpdatePref( |
| 1386 | { update_auto: autoCheck.checked }, |
| 1387 | () => (autoCheck.checked = !autoCheck.checked), |
| 1388 | autoCheck.checked ? "Updates will install automatically." : "Updates won't install until you choose to.", |
| 1389 | ); |
| 1390 | dlCheck.onchange = () => |
| 1391 | saveUpdatePref( |
| 1392 | { update_download: dlCheck.checked }, |
| 1393 | () => (dlCheck.checked = !dlCheck.checked), |
| 1394 | dlCheck.checked ? "New versions will download automatically." : "New versions won't download automatically.", |
| 1395 | ); |
| 1396 | chanSelect.onchange = async () => { |
| 1397 | if (chanSelect.value === PINNED) return; // pinning happens via deploy tooling, not here |
| 1398 | const prev = updState.channel; |
| 1399 | const revert = () => (chanSelect.value = pinnedOpt ? PINNED : prev); |
| 1400 | // Picking a channel also clears any pin — that's how updates resume. |
| 1401 | const ok = await saveUpdatePref( |
| 1402 | { update_channel: chanSelect.value, update_pin: "" }, |
| 1403 | revert, |
| 1404 | `Now on the ${chanSelect.value} channel.`, |
| 1405 | ); |
| 1406 | if (ok) { |
| 1407 | updState.channel = chanSelect.value; |
| 1408 | updState.pin = ""; |
| 1409 | showPinned(""); |
| 1410 | paintUpdateInfo(updState.update, updState.channel, ""); |
| 1411 | } |
| 1412 | }; |
| 1413 | |
| 1414 | // ---- Photo ---------------------------------------------------------------- |
| 1415 | const photoSec = section("Photo"); |
| 1416 | const photoRow = document.createElement("div"); |
| 1417 | photoRow.className = N.cls("row"); |
| 1418 | const photoImg = document.createElement("img"); |
| 1419 | photoImg.className = N.cls("photo"); |
| 1420 | photoImg.alt = ""; |
| 1421 | photoImg.style.display = "none"; |
| 1422 | const photoPick = document.createElement("button"); |
| 1423 | photoPick.textContent = "Upload"; |
| 1424 | const photoRemove = document.createElement("button"); |
| 1425 | photoRemove.className = "linkish danger"; |
| 1426 | photoRemove.textContent = "Remove"; |
| 1427 | photoRemove.style.display = "none"; |
| 1428 | photoRow.append(photoImg, photoPick, photoRemove); |
| 1429 | photoSec.appendChild(photoRow); |
| 1430 | // Alt text lives with the photo (and would otherwise be silently reset by a |
| 1431 | // photo change) — editable here without re-uploading the image. |
| 1432 | const altRow = document.createElement("div"); |
| 1433 | altRow.className = N.cls("row"); |
| 1434 | altRow.style.display = "none"; |
| 1435 | const altLabel = document.createElement("label"); |
| 1436 | altLabel.textContent = "Alt text"; |
| 1437 | const altInput = document.createElement("input"); |
| 1438 | altInput.type = "text"; |
| 1439 | altInput.maxLength = 300; |
| 1440 | altInput.placeholder = "Describe the photo"; |
| 1441 | const altSave = document.createElement("button"); |
| 1442 | altSave.textContent = "Save"; |
| 1443 | altRow.append(altLabel, altInput, altSave); |
| 1444 | photoSec.appendChild(altRow); |
| 1445 | const photoStatus = statusLine(photoSec); |
| 1446 | altSave.onclick = async () => { |
| 1447 | const ok = await bridge.setAvatarAlt(altInput.value.trim()); |
| 1448 | if (ok) { |
| 1449 | photoState.alt = altInput.value.trim(); |
| 1450 | photoImg.alt = photoState.alt; |
| 1451 | paintPreview(nameInput.value.trim(), photoState.has, photoState.alt); |
| 1452 | flash(photoStatus, "Alt text saved."); |
| 1453 | } else flash(photoStatus, "Couldn't save — try again."); |
| 1454 | }; |
| 1455 | |
| 1456 | const paintPhoto = (has) => { |
| 1457 | photoState.has = has; |
| 1458 | photoImg.style.display = has ? "" : "none"; |
| 1459 | photoRemove.style.display = has ? "" : "none"; |
| 1460 | altRow.style.display = has ? "" : "none"; // alt text only means something with a photo |
| 1461 | if (has) |
| 1462 | bridge.serverAvatarURL().then((url) => { |
| 1463 | if (url) photoImg.src = url; |
| 1464 | else photoImg.style.display = "none"; // sealed, no key to open it |
| 1465 | }); |
| 1466 | photoPick.textContent = has ? "Change" : "Upload"; |
| 1467 | paintPreview(nameInput.value.trim(), has, photoState.alt); |
| 1468 | }; |
| 1469 | |
| 1470 | bridge.groupInfo().then((info) => { |
| 1471 | if (!info) return flash(spaceStatus, "Couldn't load settings — are you an admin?"); |
| 1472 | nameInput.value = info.name || ""; |
| 1473 | descInput.value = info.description || ""; |
| 1474 | descFit(); |
| 1475 | paintPreviewDesc(); |
| 1476 | emailInput.value = info.email || ""; |
| 1477 | invCheck.checked = !!info.userInvites; |
| 1478 | aiCheck.checked = info.aiEnabled !== false; |
| 1479 | botsCheck.checked = info.botsEnabled !== false; |
| 1480 | appsCheck.checked = info.appsEnabled !== false; |
| 1481 | memberAppsCheck.checked = info.memberAppsEnabled !== false; |
| 1482 | spaceCheck.checked = info.spaceInstallsAdmins === true; |
| 1483 | autoCheck.checked = !!info.updateAuto; |
| 1484 | dlCheck.checked = !!info.updateDownload; |
| 1485 | updState.channel = info.updateChannel || "stable"; |
| 1486 | updState.update = info.update || {}; |
| 1487 | updState.pin = info.updatePin || ""; |
| 1488 | updState.major = info.updateMajor || ""; |
| 1489 | chanSelect.value = updState.channel; |
| 1490 | majorInput.value = updState.major; |
| 1491 | showPinned(updState.pin); |
| 1492 | paintUpdateInfo(updState.update, updState.channel, updState.pin); |
| 1493 | photoState.alt = info.avatarAlt || ""; |
| 1494 | photoImg.alt = photoState.alt; |
| 1495 | altInput.value = photoState.alt; |
| 1496 | paintPhoto(!!info.avatar); |
| 1497 | }); |
| 1498 | |
| 1499 | photoPick.onclick = async () => { |
| 1500 | // Pass the current alt text with the upload — the server resets alt on |
| 1501 | // every photo change, and a swap shouldn't silently drop the description. |
| 1502 | if (await bridge.pickServerPhoto(altInput.value.trim())) { |
| 1503 | photoState.alt = altInput.value.trim(); |
| 1504 | paintPhoto(true); |
| 1505 | flash(photoStatus, "Photo saved."); |
| 1506 | } |
| 1507 | }; |
| 1508 | photoRemove.onclick = async () => { |
| 1509 | if (await bridge.removeServerPhoto()) { |
| 1510 | paintPhoto(false); |
| 1511 | flash(photoStatus, "Photo removed."); |
| 1512 | } else flash(photoStatus, "Couldn't remove the photo."); |
| 1513 | }; |
| 1514 | |
| 1515 | // ---- Delegate admin role ------------------------------------------------- |
| 1516 | const delegateSec = section("Delegate admin role"); |
| 1517 | const delegateDesc = document.createElement("p"); |
| 1518 | delegateDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 12px"; |
| 1519 | delegateDesc.textContent = "Create a link someone can use to join as admin, or to claim admin role on their existing account."; |
| 1520 | delegateSec.appendChild(delegateDesc); |
| 1521 | const delegateRow = document.createElement("div"); |
| 1522 | delegateRow.className = `${N.cls("row")} ${N.cls("row-end")}`; |
| 1523 | const delegateBtn = document.createElement("button"); |
| 1524 | delegateBtn.textContent = "Create admin invite"; |
| 1525 | delegateRow.appendChild(delegateBtn); |
| 1526 | delegateSec.appendChild(delegateRow); |
| 1527 | const delegateStatus = statusLine(delegateSec); |
| 1528 | |
| 1529 | delegateBtn.onclick = async () => { |
| 1530 | delegateBtn.disabled = true; |
| 1531 | flash(delegateStatus, "Creating invite…"); |
| 1532 | const result = await bridge.createAdminInvite(); |
| 1533 | delegateBtn.disabled = false; |
| 1534 | if (!result) { |
| 1535 | flash(delegateStatus, "Couldn't create invite — try again."); |
| 1536 | return; |
| 1537 | } |
| 1538 | flash(delegateStatus, ""); |
| 1539 | showInviteModal(result.link, "Admin invite"); |
| 1540 | }; |
| 1541 | |
| 1542 | // Modal for displaying invite link (QR + link, same as the web admin console) |
| 1543 | const showInviteModal = (link, title) => { |
| 1544 | const { qrCanvas } = ctx.imports || {}; |
| 1545 | const backdrop = document.createElement("div"); |
| 1546 | 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"; |
| 1547 | const card = document.createElement("div"); |
| 1548 | card.style.cssText = "background:var(--panel);border-radius:12px;padding:24px;max-width:320px;box-shadow:0 10px 40px rgba(0,0,0,0.3)"; |
| 1549 | const h = document.createElement("h3"); |
| 1550 | h.textContent = title || "Admin invite"; |
| 1551 | h.style.cssText = "margin:0 0 12px;font-size:18px;font-weight:700"; |
| 1552 | card.appendChild(h); |
| 1553 | if (qrCanvas) { |
| 1554 | const qrDiv = document.createElement("div"); |
| 1555 | qrDiv.style.cssText = "text-align:center;margin:16px 0"; |
| 1556 | try { |
| 1557 | qrDiv.appendChild(qrCanvas(link, { scale: 6, margin: 4 })); |
| 1558 | } catch { |
| 1559 | qrDiv.textContent = "Link too long for QR"; |
| 1560 | } |
| 1561 | card.appendChild(qrDiv); |
| 1562 | } |
| 1563 | const linkBox = document.createElement("div"); |
| 1564 | linkBox.style.cssText = "background:color-mix(in srgb,var(--ink) 5%,transparent);border-radius:8px;padding:12px;font-size:11px;font-family:monospace;word-break:break-all;color:var(--muted);margin:12px 0;cursor:pointer"; |
| 1565 | linkBox.textContent = link; |
| 1566 | linkBox.title = "Tap to select"; |
| 1567 | linkBox.onclick = () => { |
| 1568 | const range = document.createRange(); |
| 1569 | range.selectNodeContents(linkBox); |
| 1570 | window.getSelection().removeAllRanges(); |
| 1571 | window.getSelection().addRange(range); |
| 1572 | }; |
| 1573 | card.appendChild(linkBox); |
| 1574 | const btnRow = document.createElement("div"); |
| 1575 | btnRow.style.cssText = "display:flex;gap:8px;margin-top:16px"; |
| 1576 | const copyBtn = document.createElement("button"); |
| 1577 | copyBtn.textContent = "Copy"; |
| 1578 | copyBtn.onclick = async () => { |
| 1579 | try { |
| 1580 | await navigator.clipboard.writeText(link); |
| 1581 | copyBtn.textContent = "Copied ✓"; |
| 1582 | setTimeout(() => (copyBtn.textContent = "Copy"), 2000); |
| 1583 | } catch { |
| 1584 | copyBtn.textContent = "Select above to copy"; |
| 1585 | } |
| 1586 | }; |
| 1587 | const closeBtn = document.createElement("button"); |
| 1588 | closeBtn.className = "secondary"; |
| 1589 | closeBtn.textContent = "Done"; |
| 1590 | closeBtn.onclick = () => backdrop.remove(); |
| 1591 | btnRow.append(copyBtn, closeBtn); |
| 1592 | card.appendChild(btnRow); |
| 1593 | backdrop.appendChild(card); |
| 1594 | document.body.appendChild(backdrop); |
| 1595 | backdrop.addEventListener("click", (e) => { |
| 1596 | if (e.target === backdrop) backdrop.remove(); |
| 1597 | }); |
| 1598 | }; |
| 1599 | |
| 1600 | // Deliberately NOT here: inviting people (part of the main chat experience — |
| 1601 | // the + menu) and the member list (people show up in New chat). The panel is |
| 1602 | // for what ONLY an admin can touch. |
| 1603 | }, |
| 1604 | }); |
| 1605 | |