// The Admin app — the in-app replacement for the /admin console page // (docs/conversational-onboarding.md §2). Admin-only: it's system-scope (never // offered in the Apps menu); the ways in are the `/admin` command and the setup // conversation's "Open admin" chip, both gated on the caller's role. // // Like the setup conversation (and unlike ordinary mini-apps), this is a // HOST-OWNED surface: instance settings are real server state, not thread // events, so everything reads and writes through the host bridge // (setSetupBridge in shell.js) — the app itself holds no tokens and no fetch // calls of its own. The one thing that DOES live in the thread's event log is // the update stream: release notes the app posts (admin.note), read in the // companion Chat view — see announceNew below. import { defineApp, namespace, setupBridge, registerFloorCommand, openAppThread } from "../apps.js"; import { ICON_WRENCH } from "../icons.js"; import { changelogParagraphs } from "../format.js"; // One declaration of the app's prefix, reused everywhere: N.cls("root") → the // "admin-root" CSS class. Change "admin" here and every class string below // follows — the prefix can't drift. const N = namespace("admin"); // A textarea that tracks its content height (the composer's behaviour): no // inner scrollbar, the field is as tall as what's written. Returns the fit // function so callers can re-run it after setting .value programmatically — // an input event only fires for typing. function fitHeight(ta) { const fit = () => { ta.style.height = "auto"; ta.style.height = ta.scrollHeight + 2 + "px"; // +2: the two 1px borders (border-box) }; ta.style.resize = "none"; ta.style.overflow = "hidden"; ta.addEventListener("input", fit); // A width change re-wraps the text (rows stack on narrow panes, panes // resize), so re-fit on any size change; the initial observe callback also // covers first layout. Settles immediately: re-applying the same height // stops the observer cascade. if (typeof ResizeObserver !== "undefined") new ResizeObserver(fit).observe(ta); else requestAnimationFrame(fit); return fit; } // ── Release notes → the Chat view ───────────────────────────────────────── // In place of the retired Announcer bot, the Admin app POSTS each new release // note into this thread as an ordinary encrypted event; chatMessage projects it // as a bubble from "Admin", so updates read as a conversation — the same // one-log-two-views model as To-do, with the chat side read-only (chatReadOnly). // State is just the ids already posted; the first-ever run baselines silently // (records everything current, posts nothing) so a new admin isn't greeted by // the whole backlog — exactly the Announcer's first-tick behaviour. // // Runs once per thread per page load, when the app renders. The check needs // state AFTER the history fold, which is exactly when render() first fires. // // An emit on a closed socket returns false — and right after a reload the // Admin thread often renders BEFORE the WebSocket is up (it reopens as the // active thread during boot). An idle thread may never render again (no events // arrive to trigger one), so a failed send can't wait for the next render: it // retries here, briefly, re-reading state each attempt. `posted` guards the // same pass re-sending an entry whose echo hasn't folded back yet. const announcedFor = new Set(); // The one live billing-changed hook — each render swaps its own closure in, // so the OAuth return leg repaints the CURRENT payments row, never a stale one. let billingHook = null; async function announceNew(ctx) { if (announcedFor.has(ctx.threadId)) return; announcedFor.add(ctx.threadId); try { const bridge = setupBridge(); if (!bridge || !bridge.changelog) return; const entries = await bridge.changelog(); // oldest-first, [] on failure if (!entries.length) return; const posted = new Set(); for (let attempt = 0; attempt < 15; attempt++) { if (attempt) await new Promise((r) => setTimeout(r, 2000)); // getState() is undefined until the thread's FIRST event folds — and on a // brand-new Admin thread the baseline we're about to post IS that first // event, so guard the empty store. const seen = new Set((ctx.getState() || {}).seen || []); let unsent = false; if (!seen.size && !posted.size) { // First run: baseline silently — record everything current, post nothing. if (await ctx.emit({ type: "admin.baseline", ids: entries.map((e) => e.id) })) return; unsent = true; } else { for (const e of entries) { if (!e.id || seen.has(e.id) || posted.has(e.id)) continue; const text = changelogParagraphs(e.text).join("\n\n"); if (!text) continue; if (await ctx.emit({ type: "admin.note", id: e.id, text })) posted.add(e.id); else { unsent = true; break; } } } if (!unsent) return; } announcedFor.delete(ctx.threadId); // still unsent after ~30s — a later render retries } catch { announcedFor.delete(ctx.threadId); } } // /admin works from any chat (like /tutorial): gated on the caller's role, it // answers in place for non-admins and opens the panel for admins. registerFloorCommand("/admin", { desc: "Open the admin panel (admins only)", run: async (_args, _threadId, reply) => { const bridge = setupBridge(); if (!bridge || !(await bridge.isAdmin())) { reply({ text: "Only this space's admins can open the admin panel." }); return; } openAppThread("admin"); }, }); export const app = defineApp({ name: "admin", title: "Admin", glyph: "🛠️", symbol: "wrench.adjustable", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md) logo: ICON_WRENCH, places: [], // host-mounted (the admin thread) — never user-instantiable snapshot: false, // A companion app that leads with its Interface: opening Admin shows the // settings panel (not the split), with the read-only update stream one // Chat-toggle away. defaultView: "interface", chatReadOnly: true, initialState: () => ({ seen: [] }), events: { // One event per release note posted into this thread; `seen` (the posted // ids) is the cross-device watermark deciding what's still new. "admin.note": (s, e) => typeof e.id === "string" && e.id && !(s.seen || []).includes(e.id) ? { seen: [...(s.seen || []), e.id] } : s, // First-run baseline: mark everything current as seen, posting nothing. "admin.baseline": (s, e) => Array.isArray(e.ids) ? { seen: [...new Set([...(s.seen || []), ...e.ids.filter((x) => typeof x === "string" && x)])] } : s, // A report-arrival line (issue #578): the bubble in the Chat view IS the // record — shell.js posts one per server-throttled burst; no state folds. "admin.report": (s) => s, }, // The Admin thread's dot lights for report lines even though your own // client wrote them (the shell's live path skips own messages; the // load-time path consults this hook, so the dot survives a reload too). unread: (e) => e.type === "admin.report", // A posted note reads as a message from Admin; the baseline is silent // housekeeping. This hook is also the companion-view opt-in (see apps.js). chatMessage(e) { if (e.type === "admin.note" && e.text) return e.text; if (e.type === "admin.report") { const n = Number(e.n) || 1; return n > 1 ? `⚠️ A report came in — ${n} are waiting in Reports.` : "⚠️ A report came in — it's waiting in Reports."; } return null; }, // The empty-state intro shown at the top of the Chat view. chatIntro: "Updates about this space are posted here — you'll see the release notes whenever your space updates itself.", styles: ` /* width:100% — as a flex item with auto margins the root SHRINK-TO-FITS, so one unshrinkable row used to size the whole page past the pane and the admin panel side-scrolled on phones. Also the container the row rules below query against. */ .admin-root { max-width: 480px; width: 100%; margin: 0 auto; padding: 28px 20px 48px; container-type: inline-size; } /* The one card on this page: a live miniature of the invitation card — the actual surface these settings control (branding renders where no key exists yet). Editing below updates it as you type. */ .admin-preview { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 28px 22px 24px; border-radius: 22px; background: var(--panel); border: 1px solid color-mix(in srgb, var(--ink) 8%, transparent); box-shadow: 0 10px 34px color-mix(in srgb, var(--ink) 10%, transparent); } .admin-preview-photo { width: 64px; height: 64px; border-radius: 16px; object-fit: cover; margin-bottom: 12px; } .admin-preview-glyph { font-size: 44px; line-height: 64px; margin-bottom: 12px; } .admin-preview-title { font-size: 18px; font-weight: 700; color: var(--ink); overflow-wrap: anywhere; } .admin-preview-sub { font-size: 13px; color: var(--muted); margin-top: 4px; } /* mirrors .welcome-desc on the real join card: the description is content to read, not chrome — a tinted panel with full-strength text. */ .admin-preview-desc { font-size: 12px; color: var(--ink); text-align: left; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 10px; padding: 8px 10px; margin-top: 8px; white-space: pre-wrap; overflow-wrap: anywhere; } .admin-preview-caption { text-align: center; font-size: 12px; color: var(--muted); margin: 10px 0 6px; } /* Sections sit unboxed on the page: a small-caps eyebrow over a hairline — quiet structure, so the preview card stays the one loud thing. */ .admin-group { border-top: 1px solid var(--border); margin-top: 26px; padding-top: 16px; } .admin-eyebrow { font-size: 11px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--muted); margin: 0 0 12px; } .admin-row { display: flex; gap: 10px; align-items: center; margin: 10px 0; flex-wrap: wrap; } .admin-row label { flex: 0 0 104px; font-size: 14px; color: var(--muted); } .admin-row input[type="text"], .admin-row input[type="email"], .admin-row select, .admin-row textarea { flex: 1 1 160px; min-width: 0; font: inherit; padding: 8px 12px; border: 1px solid var(--border); border-radius: 10px; background: transparent; color: var(--ink); } /* Textareas autogrow (the fitHeight helper owns the height); the floor keeps an empty field inviting. lh needs a current browser — without it the floor is just absent and autogrow still works. */ .admin-row textarea { resize: none; overflow: hidden; min-height: calc(3lh + 18px); } .admin-row-top { align-items: flex-start; } /* textarea rows: label hugs the top */ .admin-row-top label { padding-top: 8px; } /* Narrow pane (phones, the Mac's split sidebar): the label column doesn't fit beside its field — stack each row instead. The switch rows are untouched (their label IS the row; the more-specific rule below keeps them one line). The query measures the root's CONTENT box (its 40px padding excluded), so the full 480px root reads as 440 — 420 keeps desktop side-by-side. */ @container (max-width: 420px) { .admin-row label { flex: 1 1 100%; } .admin-row-top label { padding-top: 0; } } .admin-row input:focus-visible, .admin-row textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; } .admin-row button { flex: none; } .admin-status { font-size: 13px; color: var(--muted); min-height: 1.2em; } .admin-photo { width: 56px; height: 56px; border-radius: 12px; object-fit: cover; border: 1px solid var(--border); } .admin-row-end { justify-content: flex-end; } /* More specific than the generic .admin-row label column rule above — the switch's label IS the whole row (sentence left, switch right). */ .admin-row .admin-switch-label { display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 14px; flex: 1 1 auto; min-width: 0; } .admin-switch-text { flex: 1 1 auto; } .admin-switch { position: relative; width: 42px; height: 26px; flex: none; } .admin-switch input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; } .admin-switch .knob { position: absolute; inset: 0; border-radius: 999px; pointer-events: none; background: color-mix(in srgb, var(--ink) 22%, transparent); transition: background 0.15s ease; } .admin-switch .knob::after { content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); transition: left 0.15s ease; } .admin-switch input:checked + .knob { background: var(--accent); } .admin-switch input:checked + .knob::after { left: 19px; } @media (prefers-reduced-motion: reduce) { .admin-switch .knob, .admin-switch .knob::after { transition: none; } } `, // Full-pane takeover. render() is sync; the data arrives via the bridge, so // each section draws its skeleton and fills itself in. render(state, el, ctx) { const bridge = setupBridge(); const root = document.createElement("div"); root.className = N.cls("root"); el.appendChild(root); if (!bridge) return; // Post any release notes that shipped since the last visit into the Chat // view (this thread's read-only stream) — see announceNew below. announceNew(ctx); const section = (title) => { const s = document.createElement("div"); s.className = N.cls("group"); const h = document.createElement("h2"); h.className = N.cls("eyebrow"); h.textContent = title; s.appendChild(h); root.appendChild(s); return s; }; const statusLine = (parent) => { const p = document.createElement("div"); p.className = N.cls("status"); parent.appendChild(p); return p; }; const flash = (elm, msg) => { elm.textContent = msg; setTimeout(() => { if (elm.textContent === msg) elm.textContent = ""; }, 2500); }; // A setting-as-a-sentence row: text on the left, sliding switch on the // right. Returns the checkbox for wiring. const switchRow = (parent, text) => { const row = document.createElement("div"); row.className = N.cls("row"); const wrap = document.createElement("label"); wrap.className = N.cls("switch-label"); const t = document.createElement("span"); t.className = N.cls("switch-text"); t.textContent = text; const sw = document.createElement("span"); sw.className = N.cls("switch"); const check = document.createElement("input"); check.type = "checkbox"; const knob = document.createElement("span"); knob.className = "knob"; sw.append(check, knob); wrap.append(t, sw); row.appendChild(wrap); parent.appendChild(row); return check; }; // ---- The invitation preview: what these settings actually control ------- // A live miniature of the join card (the one keyless surface): the photo and // name here are exactly what an invited person sees, so edits below show // their effect the moment you type. const preview = document.createElement("div"); preview.className = N.cls("preview"); const previewGlyph = document.createElement("div"); previewGlyph.className = N.cls("preview-glyph"); previewGlyph.textContent = "💬"; const previewPhoto = document.createElement("img"); previewPhoto.className = N.cls("preview-photo"); previewPhoto.alt = ""; previewPhoto.style.display = "none"; const previewTitle = document.createElement("div"); previewTitle.className = N.cls("preview-title"); const previewSub = document.createElement("div"); previewSub.className = N.cls("preview-sub"); previewSub.textContent = "Private, end-to-end encrypted chat."; // The space description renders on the real join card (below the sub line), // so the miniature mirrors it — live as the admin types, hidden when empty. const previewDesc = document.createElement("div"); previewDesc.className = "admin-preview-desc"; previewDesc.style.display = "none"; preview.append(previewGlyph, previewPhoto, previewTitle, previewSub, previewDesc); root.appendChild(preview); const previewCaption = document.createElement("div"); previewCaption.className = N.cls("preview-caption"); previewCaption.textContent = "What people see when you invite them"; root.appendChild(previewCaption); const paintPreview = (name, hasPhoto, alt) => { previewTitle.textContent = "You're invited to " + (name || "your space"); previewGlyph.style.display = hasPhoto ? "none" : ""; previewPhoto.style.display = hasPhoto ? "" : "none"; if (hasPhoto) { // Via the bridge: while sealed the raw endpoint serves ciphertext, // and the shell hands back a decrypted object URL instead. bridge.serverAvatarURL().then((url) => { if (url) previewPhoto.src = url; else { previewGlyph.style.display = ""; previewPhoto.style.display = "none"; } }); previewPhoto.alt = alt || ""; } }; // ---- This space: name + contact, saved together ------------------------- const space = section("This space"); const nameRow = document.createElement("div"); nameRow.className = N.cls("row"); const nameLabel = document.createElement("label"); nameLabel.textContent = "Name"; const nameInput = document.createElement("input"); nameInput.type = "text"; nameInput.maxLength = 60; nameRow.append(nameLabel, nameInput); space.appendChild(nameRow); const photoState = { has: false, alt: "" }; nameInput.addEventListener("input", () => paintPreview(nameInput.value.trim(), photoState.has, photoState.alt)); const descRow = document.createElement("div"); descRow.className = "admin-row admin-row-top"; const descLabel = document.createElement("label"); descLabel.textContent = "Description"; const descInput = document.createElement("textarea"); descInput.rows = 3; descInput.maxLength = 500; descInput.placeholder = "What is this space? Members and the people they invite can read this."; const descFit = fitHeight(descInput); descRow.append(descLabel, descInput); space.appendChild(descRow); const paintPreviewDesc = () => { const d = descInput.value.trim(); previewDesc.textContent = d; previewDesc.style.display = d ? "" : "none"; }; descInput.addEventListener("input", paintPreviewDesc); const emailRow = document.createElement("div"); emailRow.className = N.cls("row"); const emailLabel = document.createElement("label"); emailLabel.textContent = "Contact email"; const emailInput = document.createElement("input"); emailInput.type = "email"; emailRow.append(emailLabel, emailInput); space.appendChild(emailRow); const saveRow = document.createElement("div"); saveRow.className = `${N.cls("row")} ${N.cls("row-end")}`; const save = document.createElement("button"); save.textContent = "Save"; saveRow.appendChild(save); space.appendChild(saveRow); const spaceStatus = statusLine(space); save.onclick = async () => { const result = await bridge.saveSpace({ name: nameInput.value.trim(), email: emailInput.value.trim(), description: descInput.value.trim(), }); flash( spaceStatus, result === "ok" ? "Saved." : result === "invalid" ? "That didn't save — names are 1–60 characters, and the email has to look like one." : "Couldn't save — try again.", ); }; // ---- Permissions --------------------------------------------------------- const perms = section("Permissions"); const invCheck = switchRow(perms, "Members can invite people"); const permsStatus = statusLine(perms); invCheck.onchange = async () => { const want = invCheck.checked; const ok = await bridge.setUserInvites(want); if (ok) { flash(permsStatus, want ? "Members can now invite people." : "Only admins can invite now."); } else { invCheck.checked = !want; // revert — the server didn't take it flash(permsStatus, "Couldn't save — try again."); } }; // ---- Privacy: sealed branding (docs/no-plaintext.md) ------------------------ // One switch: lock the space's name, description, photo, and admin contact // into a sealed blob only link-holders can open. The shell does all crypto; // this is pure UI over bridge.brandingSeal/brandingUnseal. const sealSec = section("Privacy"); const sealDesc = document.createElement("p"); sealDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px"; sealDesc.textContent = "Normally the space's name, photo, and description sit on the server so invite pages can show them. " + "Sealing locks them in a box only your invite links can open — the people who run the server are never sent a key. " + "Messages and photos people share are always locked, either way. " + "For now the iPhone and Mac apps can't open the box yet, so they show a generic name and no photo while sealed."; sealSec.appendChild(sealDesc); const sealCheck = switchRow(sealSec, "Seal the space's name and photo"); const sealStatus = statusLine(sealSec); sealCheck.disabled = true; bridge.brandingSealed().then((sealed) => { sealCheck.checked = sealed; sealCheck.disabled = false; }); sealCheck.onchange = async () => { const want = sealCheck.checked; sealCheck.disabled = true; const r = want ? await bridge.brandingSeal() : await bridge.brandingUnseal(); sealCheck.disabled = false; if (r === "ok") { flash( sealStatus, want ? "Sealed. New invite links carry the key; the server holds only the locked box." : "Unsealed. The name and description are readable on the server again — re-add the photo if you had one.", ); } else { sealCheck.checked = !want; // revert — the server didn't take it flash(sealStatus, "Couldn't change the seal — try again."); } }; // ---- Features -------------------------------------------------------------- // The per-instance gates (default on). Each switch is one partial write; // the server enforces the gate — these entry points just follow suit on // members' next load. const featSec = section("Features"); const featDesc = document.createElement("p"); featDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px"; featDesc.textContent = "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."; featSec.appendChild(featDesc); const featRow = (label, key, onMsg, offMsg) => { const check = switchRow(featSec, label); check.onchange = async () => { const want = check.checked; if (await bridge.setFeatures({ [key]: want })) { flash(featStatus, want ? onMsg : offMsg); } else { check.checked = !want; // revert — the server didn't take it flash(featStatus, "Couldn't save — try again."); } }; return check; }; const aiCheck = featRow( "Enable AI", "ai_enabled", "AI replies are on.", "AI replies are off — helpers answer commands only.", ); const botsCheck = featRow( "Enable bots", "bots_enabled", "Members can create bots again.", "Bot creation is off — existing bots keep running.", ); const appsCheck = featRow( "Enable apps", "apps_enabled", "Apps are back in the New-chat menu.", "Apps are off — existing app chats keep working.", ); const memberAppsCheck = featRow( "Let members make their own apps", "member_apps_enabled", "Members can make their own apps again.", "Member-made apps are off — apps already deployed keep working.", ); // Inverted semantics on purpose: ON = restricted. The registry is E2EE // fold state, so this is client-honored policy, not a server wall — the // sheet, @everyone, and read-time filtering all respect it. const spaceCheck = featRow( "Space-wide apps: admins only", "space_installs_admins", "Only admins can add apps for everyone now.", "Anyone can add apps for everyone again.", ); const featStatus = statusLine(featSec); // ---- Access levels --------------------------------------------------------- // Tiers server-side: named member sets, each with its own key held only by // its members (the shell does all crypto — this section is pure UI). const levelsSec = section("Access levels"); const levelsDesc = document.createElement("p"); levelsDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px"; levelsDesc.textContent = "Named sets of members — Coaches, Family, Founders. A group can be limited to an access level: only its members can find and join it."; levelsSec.appendChild(levelsDesc); // The one constraint worth stating: gating a group means wrapping its key // under the level's key, which only that level's members hold — so you // must be in a level to make a group limited to it (an admin who curates // a level without joining it can't read what it gates, by design). const levelsNote = document.createElement("p"); levelsNote.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 12px"; levelsNote.textContent = "To make a group limited to a level, you need to be a member of that level yourself."; levelsSec.appendChild(levelsNote); // Payments: whether members can be charged for a level here. When the // platform supports it but this space hasn't linked a Stripe account, offer // to link one; when it's linked, levels get a "Set a price" control (below). // billingReady gates the per-level price UI in buildLevelBox. let billingReady = false; const paySec = document.createElement("div"); paySec.style.cssText = "margin:0 0 12px"; levelsSec.appendChild(paySec); const refreshPay = async () => { const st = await bridge.billingStatus(); billingReady = !!(st && st.available); paySec.textContent = ""; if (!st || !st.platform) return; // payments not offered on this box — say nothing const row = document.createElement("div"); row.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:13px;color:var(--muted)"; const txt = document.createElement("span"); txt.textContent = billingReady ? `Paid access levels are on — members pay your Stripe account${st.account_id ? ` (${st.account_id})` : ""}; we take 5.5%.` : "Charge for access levels: connect your Stripe account to start."; row.appendChild(txt); // Linking goes through Stripe's own consent page (OAuth) — never a // pasted account id. No broker/OAuth on this platform → say so instead. if (await bridge.billingOAuthReady()) { const link = document.createElement("button"); link.className = "linkish"; link.textContent = billingReady ? "Change Stripe account" : "Connect Stripe account"; link.onclick = async () => { link.disabled = true; const res = await bridge.startBillingConnect(); if (!res || !res.ok) { flash((res && res.error) || "Stripe connect isn't available right now"); link.disabled = false; } // on ok the whole tab is on its way to Stripe }; row.appendChild(link); } else if (!billingReady) { txt.textContent = "Charge for access levels: Stripe connection isn't configured on this platform yet."; } paySec.appendChild(row); }; // The OAuth return leg (completeOAuthReturn in the shell) links the account // in the background and announces itself — repaint and surface the word. if (billingHook) window.removeEventListener("lchat:billing-changed", billingHook); billingHook = (e) => { refreshPay(); if (e.detail && e.detail.text) flash(e.detail.text); }; window.addEventListener("lchat:billing-changed", billingHook); const levelsList = document.createElement("div"); levelsSec.appendChild(levelsList); const levelAddRow = document.createElement("div"); levelAddRow.className = "admin-row"; const levelName = document.createElement("input"); levelName.type = "text"; levelName.maxLength = 60; levelName.placeholder = "New level name — e.g. Coach, VIP, MVP"; levelName.setAttribute("aria-label", "New access level name"); const levelAddBtn = document.createElement("button"); levelAddBtn.textContent = "Add level"; levelName.addEventListener("keydown", (e) => { // The row isn't a
(the app builds plain DOM), so give the input // the form behavior people expect: Return creates the level. if (e.key === "Enter") { e.preventDefault(); levelAddBtn.click(); } }); levelAddRow.append(levelName, levelAddBtn); levelsSec.appendChild(levelAddRow); const levelsStatus = statusLine(levelsSec); // In-place list management — add appends, delete removes, saving members // swaps one box. A full repaint (initial load only) rebuilds everything; // doing that after every action flashed the whole section blank. let people = bridge.levelMembers(); const emptyNote = document.createElement("p"); emptyNote.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px"; emptyNote.textContent = "No access levels yet."; const syncEmptyNote = () => { if (!levelsList.querySelector("[data-level]")) levelsList.appendChild(emptyNote); else emptyNote.remove(); }; const buildLevelBox = (level) => { const box = document.createElement("div"); box.className = "admin-row"; box.dataset.level = level.id; box.style.cssText = "display:block;padding:10px 0"; const head = document.createElement("div"); // wrap + a shrinkable name: a long level name used to make this row // unshrinkable, which forced the whole page wider than a phone pane. head.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap"; const name = document.createElement("strong"); name.style.cssText = "min-width:0;overflow-wrap:anywhere"; name.textContent = level.name; const count = document.createElement("span"); count.style.cssText = "font-size:13px;color:var(--muted)"; const n = (level.members || []).length; count.textContent = n === 1 ? "1 member" : `${n} members`; const spacer = document.createElement("span"); spacer.style.flex = "1"; const editBtn = document.createElement("button"); editBtn.className = "linkish"; editBtn.textContent = "Members"; editBtn.setAttribute("aria-expanded", "false"); const delBtn = document.createElement("button"); delBtn.className = "linkish danger"; delBtn.textContent = "Delete"; head.append(name, count, spacer, editBtn, delBtn); box.appendChild(head); // Who can see who's in this level. "Members choose" hands each member // the toggle in their own profile. All three hide the roster only from // NON-members — a level's members share its key, so they always know // each other. const visRow = document.createElement("div"); visRow.style.cssText = "display:flex;align-items:center;gap:8px;margin-top:6px"; const visLabel = document.createElement("span"); visLabel.style.cssText = "font-size:12px;color:var(--muted)"; visLabel.textContent = "Shown on profiles:"; const visSel = document.createElement("select"); visSel.setAttribute("aria-label", `Who sees who's in ${level.name}`); for (const [val, text] of [ ["shown", "Everyone"], ["hidden", "Only its members"], ["user", "Members choose"], ]) { const o = document.createElement("option"); o.value = val; o.textContent = text; visSel.appendChild(o); } visSel.value = level.visibility || "shown"; visSel.onchange = async () => { const prev = level.visibility || "shown"; const want = visSel.value; visSel.disabled = true; const ok = await bridge.setTierPolicy(level.id, want); visSel.disabled = false; if (ok) { level.visibility = want; flash(levelsStatus, "Saved."); } else { visSel.value = prev; flash(levelsStatus, "Couldn't save — try again."); } }; visRow.append(visLabel, visSel); box.appendChild(visRow); // Price: make a level sellable so members can subscribe. Only shown once // payments are set up for the space (billingReady); otherwise a level is // admin/invite-assigned only. Members pay the admin's Stripe account; the // platform takes a 5.5% fee. if (billingReady) { const priceRow = document.createElement("div"); priceRow.style.cssText = "display:flex;align-items:center;gap:8px;margin-top:6px;flex-wrap:wrap"; const priceLabel = document.createElement("span"); priceLabel.style.cssText = "font-size:12px;color:var(--muted)"; priceLabel.textContent = "Members can subscribe:"; const cur = level.price_cents ? `${(level.price_cents / 100).toFixed(level.price_cents % 100 ? 2 : 0)} ${(level.currency || "").toUpperCase()} / ${level.interval || "month"}` : "Not for sale"; const priceVal = document.createElement("strong"); priceVal.style.fontSize = "13px"; priceVal.textContent = cur; const setBtn = document.createElement("button"); setBtn.className = "linkish"; setBtn.textContent = level.price_cents ? "Change" : "Set a price"; setBtn.onclick = () => editLevelPrice(level, () => paintLevels()); priceRow.append(priceLabel, priceVal, setBtn); if (level.price_cents) { const clr = document.createElement("button"); clr.className = "linkish danger"; clr.textContent = "Stop selling"; clr.onclick = async () => { clr.disabled = true; const ok = await bridge.setTierPrice(level.id, { cents: 0 }); flash(levelsStatus, ok ? "No longer for sale." : "Couldn't update — try again."); await paintLevels(); }; priceRow.appendChild(clr); } box.appendChild(priceRow); } if (level.key_lost) { // Minted once, every holder gone — unrecoverable by design (keys // never re-mint under groups wrapped to the old one). Say so. const lost = document.createElement("div"); lost.style.cssText = "font-size:12px;color:var(--danger);margin-top:4px"; lost.textContent = "This level's key was lost when its last member left. Delete it and create a new one."; box.appendChild(lost); } // Members whose key copy hasn't landed yet: honest inline status, the // same "on its way" register as photo syncing (never a toast). const missing = (level.members || []).filter((uid) => !(level.have || []).includes(uid)); if (!level.key_lost && missing.length) { const wait = document.createElement("div"); wait.style.cssText = "font-size:12px;color:var(--muted);margin-top:4px"; wait.textContent = missing.length === 1 ? "1 member's key is on its way — it arrives when a key holder is online." : `${missing.length} members' keys are on their way — they arrive when a key holder is online.`; box.appendChild(wait); } // Refresh just this box from the server's rows (post-save): the box // swaps in place, the rest of the list never repaints. const refreshBox = async () => { const fresh = (await bridge.tiers()).find((t) => t.id === level.id); if (fresh) box.replaceWith(buildLevelBox(fresh)); }; let editor = null; editBtn.onclick = () => { if (editor) { editor.remove(); editor = null; editBtn.setAttribute("aria-expanded", "false"); return; } editBtn.setAttribute("aria-expanded", "true"); people = bridge.levelMembers(); // fresh roster snapshot for the picker editor = document.createElement("div"); editor.style.cssText = "margin:8px 0 4px;padding-left:4px"; const selected = new Set(level.members || []); for (const p of people) { const row = document.createElement("label"); row.style.cssText = "display:flex;align-items:center;gap:8px;padding:4px 0;cursor:pointer"; const cb = document.createElement("input"); cb.type = "checkbox"; // Escape the app-wide `input { width:100% }` rule, which would make the // checkbox a full-width flex item and shove the name to the far edge. cb.style.cssText = "width:18px;height:18px;flex:none;margin:0"; cb.checked = selected.has(p.user_id); cb.onchange = () => (cb.checked ? selected.add(p.user_id) : selected.delete(p.user_id)); const l = document.createElement("span"); l.textContent = p.label; row.append(cb, l); editor.appendChild(row); } const foot = document.createElement("div"); foot.style.cssText = "display:flex;gap:8px;margin-top:6px"; const save = document.createElement("button"); save.textContent = "Save members"; save.onclick = async () => { save.disabled = true; flash(levelsStatus, "Saving…"); const ok = await bridge.setTierMembers(level.id, [...selected]); flash(levelsStatus, ok ? "Saved." : "Couldn't save — try again."); await refreshBox(); }; const cancel = document.createElement("button"); cancel.className = "secondary"; cancel.textContent = "Cancel"; cancel.onclick = () => { editor.remove(); editor = null; editBtn.setAttribute("aria-expanded", "false"); }; foot.append(save, cancel); editor.appendChild(foot); box.appendChild(editor); }; delBtn.onclick = async () => { if (!confirm(`Delete the "${level.name}" access level?`)) return; delBtn.disabled = true; const status = await bridge.deleteTier(level.id); if (status === 200) { box.remove(); syncEmptyNote(); flash(levelsStatus, "Deleted."); return; } delBtn.disabled = false; flash( levelsStatus, status === 409 ? "Can't delete — groups are still limited to this level." : "Couldn't delete — try again.", ); }; return box; }; // A minimal admin modal (its own backdrop, like showInviteModal) for the // two billing config flows. onSave(value) returns truthy to close. const configModal = (title, fields, onSave) => { const backdrop = document.createElement("div"); backdrop.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;padding:20px"; const cardEl = document.createElement("div"); 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)"; const hh = document.createElement("h3"); hh.textContent = title; hh.style.cssText = "margin:0 0 14px;font-size:17px;font-weight:700"; cardEl.appendChild(hh); const inputs = {}; for (const f of fields) { const lab = document.createElement("label"); lab.className = "field-label"; lab.textContent = f.label; cardEl.appendChild(lab); let el; if (f.options) { el = document.createElement("select"); for (const o of f.options) { const opt = document.createElement("option"); opt.value = o.value; opt.textContent = o.label; el.appendChild(opt); } } else { el = document.createElement("input"); el.type = f.type || "text"; if (f.placeholder) el.placeholder = f.placeholder; } if (f.value != null) el.value = f.value; el.style.cssText = "width:100%;margin-bottom:10px"; cardEl.appendChild(el); inputs[f.key] = el; } const st = statusLine(cardEl); const foot = document.createElement("div"); foot.style.cssText = "display:flex;gap:8px;justify-content:flex-end;margin-top:8px"; const cancel = document.createElement("button"); cancel.className = "secondary"; cancel.textContent = "Cancel"; cancel.onclick = () => backdrop.remove(); const save = document.createElement("button"); save.textContent = "Save"; save.onclick = async () => { save.disabled = true; const vals = {}; for (const k of Object.keys(inputs)) vals[k] = inputs[k].value.trim(); const ok = await onSave(vals); if (ok) backdrop.remove(); else { save.disabled = false; flash(st, "Couldn't save — check the values and try again."); } }; foot.append(cancel, save); cardEl.appendChild(foot); backdrop.appendChild(cardEl); backdrop.addEventListener("click", (e) => e.target === backdrop && backdrop.remove()); document.body.appendChild(backdrop); const first = cardEl.querySelector("input,select"); if (first) first.focus(); }; const editLevelPrice = (level, done) => configModal( `Price for ${level.name}`, [ { key: "amount", label: "Amount", type: "number", value: level.price_cents ? level.price_cents / 100 : "" }, { key: "currency", label: "Currency", options: [ { value: "eur", label: "EUR (€)" }, { value: "usd", label: "USD ($)" }, { value: "gbp", label: "GBP (£)" }, ], }, { key: "interval", label: "Billed", options: [ { value: "month", label: "Monthly" }, { value: "year", label: "Yearly" }, ], }, ], async (v) => { const cents = Math.round(parseFloat(v.amount) * 100); if (!(cents > 0)) return false; const ok = await bridge.setTierPrice(level.id, { cents, currency: v.currency, interval: v.interval }); if (ok) await done(); return ok; }, ); const paintLevels = async () => { await refreshPay(); const levels = await bridge.tiers(); people = bridge.levelMembers(); levelsList.textContent = ""; for (const level of levels) levelsList.appendChild(buildLevelBox(level)); syncEmptyNote(); }; levelAddBtn.onclick = async () => { const name = levelName.value.trim(); if (!name) return; levelAddBtn.disabled = true; const id = await bridge.createTier(name); levelAddBtn.disabled = false; if (!id) { flash(levelsStatus, "Couldn't create — try again."); return; } levelName.value = ""; flash(levelsStatus, `"${name}" created — now add its members.`); // Appears in place — a full repaint here flashed the whole section. levelsList.appendChild(buildLevelBox({ id, name, members: [], have: [] })); syncEmptyNote(); }; paintLevels(); // ---- Reports (issue #578) -------------------------------------------------- // The moderation inbox. Reports are testimony: the row shows who says what // about whom (decrypted from the blob sealed to THIS admin) plus the // repeat signals; the actions are the ladder — dismiss, warn, remove from // the group, remove an access level, remove from the space. const repSec = section("Reports"); const repDesc = document.createElement("p"); repDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px"; repDesc.textContent = "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."; repSec.appendChild(repDesc); const repList = document.createElement("div"); repSec.appendChild(repList); const repStatus = statusLine(repSec); const fmtWhen = (ts) => new Date(ts * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }); // Queue actions append audit entries, so resolving a report repaints the // Audit section too (bound once that section exists below). let repaintAudit = () => {}; const buildReportBox = (rep, refresh) => { const box = document.createElement("div"); box.className = "admin-report" + (rep.status === "open" ? "" : " resolved"); const head = document.createElement("div"); head.className = "admin-report-head"; const title = document.createElement("strong"); const cat = rep.content && rep.content.category ? rep.content.category : "report"; title.textContent = `${rep.reported_name} — ${cat}`; head.appendChild(title); const when = document.createElement("span"); when.className = "admin-report-when"; when.textContent = fmtWhen(rep.created_at); head.appendChild(when); box.appendChild(head); const byline = document.createElement("div"); byline.className = "admin-report-byline"; const repeat = (rep.reported_count > 1 ? ` · named in ${rep.reported_count} reports` : "") + (rep.reporter_count > 1 ? ` · reporter has filed ${rep.reporter_count}` : ""); // Invite hygiene (issue #578): how the reported member got in. const lineage = rep.invited_by_name ? ` · invited by ${rep.invited_by_name}` : ""; byline.textContent = `Reported by ${rep.reporter_name}` + (rep.thread_name ? ` in ${rep.thread_name}` : "") + repeat + lineage; box.appendChild(byline); if (rep.content) { if (rep.content.text) { const words = document.createElement("p"); words.className = "admin-report-words"; words.textContent = rep.content.text; box.appendChild(words); } for (const ev of rep.content.evidence || []) { const q = document.createElement("blockquote"); q.className = "report-quote"; const qwho = document.createElement("strong"); qwho.textContent = (ev.name || rep.reported_name) + ": "; q.appendChild(qwho); q.appendChild(document.createTextNode(ev.text || (ev.attachments || []).join(", "))); box.appendChild(q); } } else if (rep.status === "open") { const na = document.createElement("p"); na.className = "admin-report-words"; na.style.fontStyle = "italic"; // Missing content has more than one possible cause (filed before this // admin existed, or the reporter's tab had a stale roster at filing // time and silently omitted them) and the server has no reliable way // to tell them apart after the fact — so this stays honest rather // than asserting a specific one. na.textContent = "Not readable — its contents weren't sealed to you at filing time."; box.appendChild(na); } if (rep.status !== "open") { const done = document.createElement("div"); done.className = "admin-report-byline"; done.textContent = `Resolved (${rep.action || "handled"})${rep.resolved_name ? ` by ${rep.resolved_name}` : ""}`; box.appendChild(done); return box; } // The action ladder. Each performs its act, then closes the report with // the matching outcome so the queue and audit log tell one story. const acts = document.createElement("div"); acts.className = "admin-report-actions"; // run() resolves to: true — the deed is done, repaint the queue; // "keep" — nothing resolved (a cancelled confirm, or an expander now // waiting for input), leave the row exactly as it is; false — failure. const actBtn = (label, danger, run) => { const b = document.createElement("button"); b.className = danger ? "danger" : "secondary"; b.textContent = label; b.onclick = async () => { for (const x of acts.querySelectorAll("button")) x.disabled = true; const ok = await run(); if (ok === "keep") { for (const x of acts.querySelectorAll("button")) x.disabled = false; return; } if (!ok) { for (const x of acts.querySelectorAll("button")) x.disabled = false; flash(repStatus, "Couldn't do that — try again."); return; } refresh(); }; acts.appendChild(b); return b; }; // Inline expander under the buttons — details render in place (never a // floating dialog over content someone's reading). const detail = document.createElement("div"); detail.className = "admin-report-detail"; const openDetail = (build) => { detail.textContent = ""; build(detail); }; actBtn("Dismiss", false, () => bridge.resolveReport(rep.id, "dismissed")); actBtn("Warn…", false, async () => { openDetail((d) => { const ta = document.createElement("textarea"); ta.rows = 3; ta.style.width = "100%"; ta.value = "A report was filed about your behaviour here. Please keep this space welcoming — continued reports can mean removal."; ta.setAttribute("aria-label", `Warning to ${rep.reported_name}`); fitHeight(ta); const send = document.createElement("button"); send.textContent = `Send warning to ${rep.reported_name}`; send.onclick = async () => { send.disabled = true; if (!(await bridge.warnUser(rep.reported_id, ta.value.trim(), rep.id)) || !(await bridge.resolveReport(rep.id, "warned"))) { send.disabled = false; flash(repStatus, "Couldn't send the warning — try again."); return; } refresh(); }; d.append(ta, send); ta.focus(); }); return "keep"; // the expander takes it from here; report stays open }); if (rep.thread_id && !rep.reported_gone) { actBtn("Remove from group", true, async () => { if (!confirm(`Remove ${rep.reported_name} from ${rep.thread_name || "that group"}?`)) return "keep"; if (!(await bridge.removeFromThread(rep.thread_id, rep.reported_id))) return false; return bridge.resolveReport(rep.id, "removed_thread"); }); } if (!rep.reported_gone) { // The demote rung, only when they hold a level to lose (tiers tie-in). bridge.memberLevels(rep.reported_id).then((levels) => { if (!levels || !levels.length) return; actBtn(`Remove access level…`, true, async () => { openDetail((d) => { const sel = document.createElement("select"); sel.setAttribute("aria-label", "Access level to remove"); for (const l of levels) { const o = document.createElement("option"); o.value = l.id; o.textContent = l.name; sel.appendChild(o); } const go = document.createElement("button"); go.className = "danger"; go.textContent = `Remove level from ${rep.reported_name}`; go.onclick = async () => { go.disabled = true; if (!(await bridge.removeFromLevel(sel.value, rep.reported_id)) || !(await bridge.resolveReport(rep.id, "demoted"))) { go.disabled = false; flash(repStatus, "Couldn't remove the level — try again."); return; } refresh(); }; d.append(sel, go); }); return "keep"; // expander flow }); }); actBtn("Remove from space", true, async () => { if ( !confirm( `Remove ${rep.reported_name} from this space? Their messages and access are deleted. This can't be undone.`, ) ) return "keep"; if (!(await bridge.removeFromSpace(rep.reported_id))) return false; return bridge.resolveReport(rep.id, "removed_space"); }); // The top rung: removal that stays removed — their key can't come // back through any invite (a fresh identity still could; pausing // member invites is the companion move during a spam wave). actBtn("Remove & ban", true, async () => { if ( !confirm( `Ban ${rep.reported_name}? They're removed from the space and can't rejoin with any invite. This can't be undone.`, ) ) return "keep"; if (!(await bridge.banFromSpace(rep.reported_id))) return false; return bridge.resolveReport(rep.id, "banned"); }); } box.appendChild(acts); box.appendChild(detail); return box; }; const paintReports = async () => { const reports = await bridge.reports(); repList.textContent = ""; if (reports === null) { flash(repStatus, "Couldn't load reports."); return; } if (!reports.length) { const empty = document.createElement("p"); empty.style.cssText = "font-size:13px;color:var(--muted);margin:0"; empty.textContent = "No reports. Quiet is good."; repList.appendChild(empty); return; } for (const rep of reports) repList.appendChild( buildReportBox(rep, () => { paintReports(); repaintAudit(); }), ); }; paintReports(); // ---- Audit log (issue #578) ------------------------------------------------- // Every moderation action, append-only: who did what to whom, when, off // which report. Actions only — any quoted words stay sealed to admin keys. const audSec = section("Audit log"); const audList = document.createElement("div"); audSec.appendChild(audList); const audMoreRow = document.createElement("div"); audSec.appendChild(audMoreRow); const AUD_VERBS = { "member.removed": "removed __S__ from the space", "member.warned": "warned __S__", "thread.removed": "removed __S__ from a group", "level.removed": "removed an access level from __S__", "report.resolved": "resolved a report", "member.banned": "banned __S__", "invites.paused": "paused member invites", "invites.resumed": "resumed member invites", }; let audBefore = 0; const paintAudit = async (append) => { const entries = await bridge.audit(append ? audBefore : 0); if (!append) audList.textContent = ""; if (entries === null) return; if (!entries.length && !append) { const empty = document.createElement("p"); empty.style.cssText = "font-size:13px;color:var(--muted);margin:0"; empty.textContent = "Nothing yet — moderation actions will be recorded here."; audList.appendChild(empty); audMoreRow.textContent = ""; return; } for (const en of entries) { const row = document.createElement("div"); row.className = "admin-audit-row"; const what = (AUD_VERBS[en.action] || en.action).replace("__S__", en.subject_name || "a member"); const line = document.createElement("span"); line.textContent = `${en.actor_name} ${what}` + (en.detail && en.action === "report.resolved" ? ` (${en.detail})` : ""); const when = document.createElement("span"); when.className = "admin-report-when"; when.textContent = fmtWhen(en.ts); row.append(line, when); audList.appendChild(row); audBefore = en.id; } audMoreRow.textContent = ""; if (entries.length >= 100) { const more = document.createElement("button"); more.className = "secondary"; more.textContent = "Show older"; more.onclick = () => paintAudit(true); audMoreRow.appendChild(more); } }; paintAudit(false); repaintAudit = () => paintAudit(false); // ---- Updates --------------------------------------------------------------- const updSec = section("Updates"); const updDesc = document.createElement("p"); updDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 12px"; updDesc.textContent = "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."; updSec.appendChild(updDesc); const updInfo = document.createElement("p"); updInfo.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 12px"; updSec.appendChild(updInfo); const autoCheck = switchRow(updSec, "Install updates automatically"); const dlCheck = switchRow(updSec, "Download new versions automatically"); const chanRow = document.createElement("div"); chanRow.className = N.cls("row"); const chanLabel = document.createElement("label"); chanLabel.textContent = "Channel"; const chanSelect = document.createElement("select"); for (const [value, text] of [ ["stable", "Stable — well tested (recommended)"], ["beta", "Beta — near-stable"], ["edge", "Edge — the bleeding edge"], ]) { const o = document.createElement("option"); o.value = value; o.textContent = text; chanSelect.appendChild(o); } chanRow.append(chanLabel, chanSelect); updSec.appendChild(chanRow); // The major pin (update_major): follow the channel for minors/patches, // wait at a major-version boundary until the admin raises this. const majorRow = document.createElement("div"); majorRow.className = "admin-row"; const majorLabel = document.createElement("label"); majorLabel.textContent = "Stay on major version"; majorLabel.htmlFor = "adm-update-major"; const majorInput = document.createElement("input"); majorInput.type = "text"; majorInput.id = "adm-update-major"; majorInput.inputMode = "numeric"; majorInput.placeholder = "any"; majorInput.style.width = "72px"; majorRow.append(majorLabel, majorInput); updSec.appendChild(majorRow); const majorHint = document.createElement("p"); majorHint.style.cssText = "font-size:12px;color:var(--muted);margin:4px 0 12px"; majorHint.textContent = "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."; updSec.appendChild(majorHint); const updStatus = statusLine(updSec); const paintUpdateInfo = (update, channel, pin) => { const u = update || {}; let line = `Running build ${u.build || "unknown"}`; if (pin) { line += ` — pinned to ${pin}; automatic updates are paused (pick a channel to resume)`; } else if (u.available_version && u.available_version !== u.build) { const label = u.available_semver ? `v${u.available_semver} (${u.available_version})` : u.available_version; line += ` — ${label} is available on ${channel}`; if (u.major_held) { line += ` (held: it crosses the major version this space stays on)`; } else if (u.bake_hours) { // The supply-chain safety hold: new releases wait before auto-installing. line += ` (auto-installs after a ${u.bake_hours}-hour safety hold)`; } } else if (u.checked_at) { line += ` — up to date on ${channel}`; } else { line += " — hasn't checked for updates yet"; } updInfo.textContent = line + "."; }; // A pin shows as an extra selected option in the channel picker; choosing // a real channel clears it (one combined write) and the option disappears. const PINNED = "__pinned__"; let pinnedOpt = null; const showPinned = (pin) => { if (pinnedOpt) { pinnedOpt.remove(); pinnedOpt = null; } if (pin) { pinnedOpt = document.createElement("option"); pinnedOpt.value = PINNED; pinnedOpt.textContent = `Pinned to ${pin}`; chanSelect.appendChild(pinnedOpt); chanSelect.value = PINNED; } }; const updState = { update: {}, channel: "stable", pin: "", major: "" }; const saveUpdatePref = async (patch, revert, saved) => { if (await bridge.setUpdatePrefs(patch)) { flash(updStatus, saved); return true; } revert(); flash(updStatus, "Couldn't save — try again."); return false; }; majorInput.onchange = async () => { const v = majorInput.value.trim(); if (v !== "" && !/^\d{1,4}$/.test(v)) { flash(updStatus, "Major version must be a number — or empty to follow everything."); majorInput.value = updState.major; return; } const prev = updState.major; const ok = await saveUpdatePref( { update_major: v }, () => (majorInput.value = prev), v ? `Staying on major version ${v} — bigger jumps wait for you.` : "Following every release.", ); if (ok) { updState.major = v; majorInput.value = v; } }; autoCheck.onchange = () => saveUpdatePref( { update_auto: autoCheck.checked }, () => (autoCheck.checked = !autoCheck.checked), autoCheck.checked ? "Updates will install automatically." : "Updates won't install until you choose to.", ); dlCheck.onchange = () => saveUpdatePref( { update_download: dlCheck.checked }, () => (dlCheck.checked = !dlCheck.checked), dlCheck.checked ? "New versions will download automatically." : "New versions won't download automatically.", ); chanSelect.onchange = async () => { if (chanSelect.value === PINNED) return; // pinning happens via deploy tooling, not here const prev = updState.channel; const revert = () => (chanSelect.value = pinnedOpt ? PINNED : prev); // Picking a channel also clears any pin — that's how updates resume. const ok = await saveUpdatePref( { update_channel: chanSelect.value, update_pin: "" }, revert, `Now on the ${chanSelect.value} channel.`, ); if (ok) { updState.channel = chanSelect.value; updState.pin = ""; showPinned(""); paintUpdateInfo(updState.update, updState.channel, ""); } }; // ---- Photo ---------------------------------------------------------------- const photoSec = section("Photo"); const photoRow = document.createElement("div"); photoRow.className = N.cls("row"); const photoImg = document.createElement("img"); photoImg.className = N.cls("photo"); photoImg.alt = ""; photoImg.style.display = "none"; const photoPick = document.createElement("button"); photoPick.textContent = "Upload"; const photoRemove = document.createElement("button"); photoRemove.className = "linkish danger"; photoRemove.textContent = "Remove"; photoRemove.style.display = "none"; photoRow.append(photoImg, photoPick, photoRemove); photoSec.appendChild(photoRow); // Alt text lives with the photo (and would otherwise be silently reset by a // photo change) — editable here without re-uploading the image. const altRow = document.createElement("div"); altRow.className = N.cls("row"); altRow.style.display = "none"; const altLabel = document.createElement("label"); altLabel.textContent = "Alt text"; const altInput = document.createElement("input"); altInput.type = "text"; altInput.maxLength = 300; altInput.placeholder = "Describe the photo"; const altSave = document.createElement("button"); altSave.textContent = "Save"; altRow.append(altLabel, altInput, altSave); photoSec.appendChild(altRow); const photoStatus = statusLine(photoSec); altSave.onclick = async () => { const ok = await bridge.setAvatarAlt(altInput.value.trim()); if (ok) { photoState.alt = altInput.value.trim(); photoImg.alt = photoState.alt; paintPreview(nameInput.value.trim(), photoState.has, photoState.alt); flash(photoStatus, "Alt text saved."); } else flash(photoStatus, "Couldn't save — try again."); }; const paintPhoto = (has) => { photoState.has = has; photoImg.style.display = has ? "" : "none"; photoRemove.style.display = has ? "" : "none"; altRow.style.display = has ? "" : "none"; // alt text only means something with a photo if (has) bridge.serverAvatarURL().then((url) => { if (url) photoImg.src = url; else photoImg.style.display = "none"; // sealed, no key to open it }); photoPick.textContent = has ? "Change" : "Upload"; paintPreview(nameInput.value.trim(), has, photoState.alt); }; bridge.groupInfo().then((info) => { if (!info) return flash(spaceStatus, "Couldn't load settings — are you an admin?"); nameInput.value = info.name || ""; descInput.value = info.description || ""; descFit(); paintPreviewDesc(); emailInput.value = info.email || ""; invCheck.checked = !!info.userInvites; aiCheck.checked = info.aiEnabled !== false; botsCheck.checked = info.botsEnabled !== false; appsCheck.checked = info.appsEnabled !== false; memberAppsCheck.checked = info.memberAppsEnabled !== false; spaceCheck.checked = info.spaceInstallsAdmins === true; autoCheck.checked = !!info.updateAuto; dlCheck.checked = !!info.updateDownload; updState.channel = info.updateChannel || "stable"; updState.update = info.update || {}; updState.pin = info.updatePin || ""; updState.major = info.updateMajor || ""; chanSelect.value = updState.channel; majorInput.value = updState.major; showPinned(updState.pin); paintUpdateInfo(updState.update, updState.channel, updState.pin); photoState.alt = info.avatarAlt || ""; photoImg.alt = photoState.alt; altInput.value = photoState.alt; paintPhoto(!!info.avatar); }); photoPick.onclick = async () => { // Pass the current alt text with the upload — the server resets alt on // every photo change, and a swap shouldn't silently drop the description. if (await bridge.pickServerPhoto(altInput.value.trim())) { photoState.alt = altInput.value.trim(); paintPhoto(true); flash(photoStatus, "Photo saved."); } }; photoRemove.onclick = async () => { if (await bridge.removeServerPhoto()) { paintPhoto(false); flash(photoStatus, "Photo removed."); } else flash(photoStatus, "Couldn't remove the photo."); }; // ---- Delegate admin role ------------------------------------------------- const delegateSec = section("Delegate admin role"); const delegateDesc = document.createElement("p"); delegateDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 12px"; delegateDesc.textContent = "Create a link someone can use to join as admin, or to claim admin role on their existing account."; delegateSec.appendChild(delegateDesc); const delegateRow = document.createElement("div"); delegateRow.className = `${N.cls("row")} ${N.cls("row-end")}`; const delegateBtn = document.createElement("button"); delegateBtn.textContent = "Create admin invite"; delegateRow.appendChild(delegateBtn); delegateSec.appendChild(delegateRow); const delegateStatus = statusLine(delegateSec); delegateBtn.onclick = async () => { delegateBtn.disabled = true; flash(delegateStatus, "Creating invite…"); const result = await bridge.createAdminInvite(); delegateBtn.disabled = false; if (!result) { flash(delegateStatus, "Couldn't create invite — try again."); return; } flash(delegateStatus, ""); showInviteModal(result.link, "Admin invite"); }; // Modal for displaying invite link (QR + link, same as the web admin console) const showInviteModal = (link, title) => { const { qrCanvas } = ctx.imports || {}; const backdrop = document.createElement("div"); backdrop.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999"; const card = document.createElement("div"); card.style.cssText = "background:var(--panel);border-radius:12px;padding:24px;max-width:320px;box-shadow:0 10px 40px rgba(0,0,0,0.3)"; const h = document.createElement("h3"); h.textContent = title || "Admin invite"; h.style.cssText = "margin:0 0 12px;font-size:18px;font-weight:700"; card.appendChild(h); if (qrCanvas) { const qrDiv = document.createElement("div"); qrDiv.style.cssText = "text-align:center;margin:16px 0"; try { qrDiv.appendChild(qrCanvas(link, { scale: 6, margin: 4 })); } catch { qrDiv.textContent = "Link too long for QR"; } card.appendChild(qrDiv); } const linkBox = document.createElement("div"); 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"; linkBox.textContent = link; linkBox.title = "Tap to select"; linkBox.onclick = () => { const range = document.createRange(); range.selectNodeContents(linkBox); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); }; card.appendChild(linkBox); const btnRow = document.createElement("div"); btnRow.style.cssText = "display:flex;gap:8px;margin-top:16px"; const copyBtn = document.createElement("button"); copyBtn.textContent = "Copy"; copyBtn.onclick = async () => { try { await navigator.clipboard.writeText(link); copyBtn.textContent = "Copied ✓"; setTimeout(() => (copyBtn.textContent = "Copy"), 2000); } catch { copyBtn.textContent = "Select above to copy"; } }; const closeBtn = document.createElement("button"); closeBtn.className = "secondary"; closeBtn.textContent = "Done"; closeBtn.onclick = () => backdrop.remove(); btnRow.append(copyBtn, closeBtn); card.appendChild(btnRow); backdrop.appendChild(card); document.body.appendChild(backdrop); backdrop.addEventListener("click", (e) => { if (e.target === backdrop) backdrop.remove(); }); }; // Deliberately NOT here: inviting people (part of the main chat experience — // the + menu) and the member list (people show up in New chat). The panel is // for what ONLY an admin can touch. }, });