| 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); |