eleven / messenger Public

admin: robust group-key model (master link + key-required gate)

Fixes silent chat-splits where each admin browser minted its own group key. resolveGroupKey() now adopts a key from the admin link #fragment (canonical master link), else the local key, else mints one ONLY for a brand-new chat (zero invites), else shows an 'Enter the chat key' screen instead of generating a divergent one. Adds a 'Your master admin link' section (token + key) to save in a password manager.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 1cf9b4b2c9a34638521697331dc16d9454c50959 parent a3f5ba2
2 files changed, +127 −38
  • web/pages/admin.html +24 −0
  • web/static/admin.js +103 −38
diff --git a/web/pages/admin.html b/web/pages/admin.html
index 8affc07..579da3d 100644
--- a/web/pages/admin.html
+++ b/web/pages/admin.html
@@ -12,7 +12,31 @@
<p>Open the admin link that was printed when you started the server. It contains your admin token.</p>
</div>
+ <div id="keyRequired" class="card hidden">
+ <h1>Enter the chat key</h1>
+ <p>
+ This chat already exists, but this browser doesn't have its encryption key.
+ Paste the key — from your master admin link, your password manager, or
+ <strong>Copy key</strong> on a device that's already set up.
+ Don't have it? A new key would start a <em>separate</em> chat that current
+ members can't read.
+ </p>
+ <input id="keyRequiredInput" placeholder="Paste the chat key" class="mono" />
+ <div class="row"><button id="keyRequiredApply">Use this key</button></div>
+ </div>
+
<div id="console" class="hidden">
+ <section>
+ <h2>Your master admin link</h2>
+ <p class="sub" style="margin:0 0 8px">
+ Opens this admin on any device with both your admin access and the chat key.
+ Save it somewhere safe (e.g. a password manager) — it's how you manage the
+ chat from a new phone or browser <strong>without splitting it</strong>.
+ </p>
+ <div class="key-box mono" id="masterLink"></div>
+ <div class="row"><button class="secondary" id="copyMaster">Copy master link</button></div>
+ </section>
+
<section>
<h2>Group name</h2>
<p class="sub" style="margin:0 0 8px">Shown as the app name when added to a Home Screen, and as the chat's title.</p>
diff --git a/web/static/admin.js b/web/static/admin.js
index c5b4806..bb9cc5f 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -3,16 +3,16 @@ import { generateKey, importKey } from "/static/crypto.js";
const ADMIN_KEY = "lchat_admin_token";
const GROUP_KEY = "lchat_group_key";
-// Admin token comes from the #fragment so it isn't sent on the page load.
+// Admin token + chat key both ride in the #fragment so they aren't sent on page load.
const hash = new URLSearchParams(location.hash.slice(1));
const fragToken = hash.get("token");
-if (fragToken) {
- localStorage.setItem(ADMIN_KEY, fragToken);
- history.replaceState(null, "", location.pathname); // scrub it from the URL bar
-}
+const fragKey = hash.get("k");
+if (fragToken) localStorage.setItem(ADMIN_KEY, fragToken);
+if (fragToken || fragKey) history.replaceState(null, "", location.pathname); // scrub URL bar
const adminToken = localStorage.getItem(ADMIN_KEY);
const locked = document.getElementById("locked");
+const keyRequired = document.getElementById("keyRequired");
const console_ = document.getElementById("console");
init();
@@ -20,56 +20,73 @@ init();
async function init() {
if (!adminToken) return show(locked);
- // Verify the token by trying to list invites.
+ // Verify the token by listing invites (also tells us whether the chat already exists).
const res = await api("GET", "/api/admin/invites");
if (!res.ok) {
localStorage.removeItem(ADMIN_KEY);
return show(locked);
}
+ const invites = (await res.json()).invites || [];
+
+ // Resolve the chat key before showing anything that depends on it.
+ if (!(await resolveGroupKey(invites))) return; // key-required screen shown; await paste
+
+ hide(locked);
+ hide(keyRequired);
show(console_);
- await ensureGroupKey();
wireKeyControls();
+ wireMasterLink();
wireGroupName();
wireTestPush();
wireCreate();
- renderInvites((await res.json()).invites || []);
-}
-
-function wireTestPush() {
- document.getElementById("testPush").onclick = async () => {
- const res = await api("POST", "/api/admin/test-push");
- if (!res.ok) return toast("Couldn't send test");
- const { sent } = await res.json();
- toast(`Test sent to ${sent} device(s)`);
- };
-}
-
-// ---- group name ---------------------------------------------------------
-
-async function wireGroupName() {
- const input = document.getElementById("groupName");
- try {
- const { name } = await (await fetch("/api/group")).json();
- if (name) input.value = name;
- } catch {}
- document.getElementById("nameForm").onsubmit = async (e) => {
- e.preventDefault();
- const name = input.value.trim();
- if (!name) return;
- const res = await api("POST", "/api/admin/group", { name });
- toast(res.ok ? "Group name saved" : "Couldn't save name");
- };
+ refreshKeyUI();
+ renderInvites(invites);
}
// ---- group key ----------------------------------------------------------
-async function ensureGroupKey() {
- if (!localStorage.getItem(GROUP_KEY)) {
+// Decide which key this browser should use, robustly avoiding accidental splits.
+async function resolveGroupKey(invites) {
+ // 1. A key in the fragment is the canonical "master link" — always adopt it.
+ if (fragKey) {
+ try {
+ await importKey(fragKey); // validate
+ localStorage.setItem(GROUP_KEY, fragKey);
+ return true;
+ } catch {
+ // malformed fragment key — ignore and fall through to other sources
+ }
+ }
+ // 2. A key from a previous visit on this browser.
+ if (localStorage.getItem(GROUP_KEY)) return true;
+ // 3. No key anywhere: only mint one for a brand-new chat (nobody invited yet).
+ if (invites.length === 0) {
const { b64 } = await generateKey();
localStorage.setItem(GROUP_KEY, b64);
+ return true;
}
- document.getElementById("keyText").textContent = localStorage.getItem(GROUP_KEY);
+ // 4. Established chat, but this browser has no key. Never silently generate —
+ // that would start a separate chat the current members can't read. Ask for it.
+ showKeyRequired();
+ return false;
+}
+
+function showKeyRequired() {
+ hide(locked);
+ hide(console_);
+ show(keyRequired);
+ document.getElementById("keyRequiredApply").onclick = async () => {
+ const v = document.getElementById("keyRequiredInput").value.trim();
+ if (!v) return;
+ try {
+ await importKey(v); // validate
+ } catch {
+ return toast("That doesn't look like a valid key");
+ }
+ localStorage.setItem(GROUP_KEY, v);
+ init(); // re-run; the key now resolves
+ };
}
function wireKeyControls() {
@@ -89,12 +106,56 @@ function wireKeyControls() {
return toast("That doesn't look like a valid key");
}
localStorage.setItem(GROUP_KEY, v);
- document.getElementById("keyText").textContent = v;
+ refreshKeyUI();
importArea.classList.add("hidden");
toast("Key updated — new invites will use it");
};
}
+// ---- master admin link --------------------------------------------------
+
+function masterLink() {
+ return `${location.origin}/admin#token=${adminToken}&k=${localStorage.getItem(GROUP_KEY)}`;
+}
+
+function wireMasterLink() {
+ document.getElementById("copyMaster").onclick = () =>
+ copy(masterLink(), "Master link copied");
+}
+
+function refreshKeyUI() {
+ document.getElementById("keyText").textContent = localStorage.getItem(GROUP_KEY);
+ document.getElementById("masterLink").textContent = masterLink();
+}
+
+// ---- group name ---------------------------------------------------------
+
+async function wireGroupName() {
+ const input = document.getElementById("groupName");
+ try {
+ const { name } = await (await fetch("/api/group")).json();
+ if (name) input.value = name;
+ } catch {}
+ document.getElementById("nameForm").onsubmit = async (e) => {
+ e.preventDefault();
+ const name = input.value.trim();
+ if (!name) return;
+ const res = await api("POST", "/api/admin/group", { name });
+ toast(res.ok ? "Group name saved" : "Couldn't save name");
+ };
+}
+
+// ---- notifications ------------------------------------------------------
+
+function wireTestPush() {
+ document.getElementById("testPush").onclick = async () => {
+ const res = await api("POST", "/api/admin/test-push");
+ if (!res.ok) return toast("Couldn't send test");
+ const { sent } = await res.json();
+ toast(`Test sent to ${sent} device(s)`);
+ };
+}
+
// ---- invites ------------------------------------------------------------
function wireCreate() {
@@ -186,6 +247,10 @@ function show(el) {
el.classList.remove("hidden");
}
+function hide(el) {
+ el.classList.add("hidden");
+}
+
async function copy(text, msg) {
try {
await navigator.clipboard.writeText(text);