eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
13import { defineApp, namespace, setupBridge, registerFloorCommand, openAppThread } from "../apps.js";
14import { ICON_WRENCH } from "../icons.js";
15import { 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.
20const 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.
26function 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.
61const 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.
64let billingHook = null;
65async 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.
107registerFloorCommand("/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
119export 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