// 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