eleven / messenger Public

web: extract the add-a-device sheet into adddevice.js (#969)

One function, verbatim, through the verbs seam. Audit caught its three
missing imports (crypto trio, qrCanvas, launchEnrol).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 1d4a91e72801d5fdf8777d2f73891b3674497ced parent b6fde7f
3 files changed, +94 −83
  • CLAUDE.md +2 −0
  • web/static/adddevice.js +91 −0
  • web/static/shell.js +1 −83
diff --git a/CLAUDE.md b/CLAUDE.md
index cb297bb..d97634f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -201,6 +201,8 @@ Frontend module layout:
group, members, profiles, mute/pin, leave/close), through the verbs seam.
- `newchat.js` — the roster picker, membership view, Discover, apps
catalogue, and thread creation, through the verbs seam.
+- `adddevice.js` — the add-a-device sheet (key-invite + QR; secret rides the
+ fragment).
- `store.js` — the local message store (#457): IndexedDB cache of RAW server
responses (ciphertext rows + wrapped keys, never plaintext) that boot and
thread-opens hydrate from before reconciling over the network. Its pure
diff --git a/web/static/adddevice.js b/web/static/adddevice.js
new file mode 100644
index 0000000..7c0b22d
--- /dev/null
+++ b/web/static/adddevice.js
@@ -0,0 +1,91 @@
+// Add a device — mints a key-invite for this member and shows the sign-in
+// link + QR (the fragment carries the one-time secret; the server never sees
+// it). Extracted verbatim from shell.js.
+import { verbs } from "/static/shellverbs.js";
+import { session } from "/static/shellstate.js";
+import { deriveInvite, randomSecret, encrypt } from "/static/crypto.js";
+import { qrCanvas } from "/static/qrcode.js";
+import { launchEnrol } from "/static/backup.js";
+
+// ---- add a device -------------------------------------------------------
+
+export async function addDevice() {
+ let link;
+ try {
+ const T = randomSecret();
+ const { id, wrapKey, claimHash } = await deriveInvite(T);
+ // Ship this user's keypair (so the new device can read every thread).
+ const wrapped = await encrypt(wrapKey, { pub: session.userPub, priv: session.userPriv });
+ const res = await fetch("/api/device-invites", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, id, wrapped, claim_hash: claimHash }),
+ });
+ if (!res.ok) throw new Error("server");
+ // Neutral slug; the name rides in the #fragment (never sent to the server).
+ link = `${location.origin}/join/join#${buildInviteFragment(T, session.name, brandingB || "")}`;
+ } catch {
+ return alert("Couldn't create a device link. Try again.");
+ }
+ const backdrop = verbs.modal();
+ const card = backdrop.querySelector(".sheet");
+ const h = document.createElement("h2");
+ h.textContent = "Add a device or back up";
+ const note = document.createElement("p");
+ note.className = "sub";
+ note.textContent = "Open this on another device to sign in there — or save it somewhere safe (like a password manager) as your backup: it's the only way back in if you lose this device. It works once, and anyone who uses it signs in as you.";
+ const qr = document.createElement("div");
+ qr.className = "qr-wrap";
+ try {
+ qr.appendChild(qrCanvas(link, { scale: 6, margin: 4 }));
+ } catch {
+ qr.textContent = "This link is too long to show as a QR code — use the link below.";
+ }
+ const box = document.createElement("div");
+ box.className = "key-box mono";
+ box.textContent = link;
+ box.onclick = () => {
+ const range = document.createRange();
+ range.selectNodeContents(box);
+ const sel = getSelection();
+ sel.removeAllRanges();
+ sel.addRange(range);
+ };
+ const copy = document.createElement("button");
+ copy.className = "secondary";
+ copy.textContent = "Copy link";
+ copy.onclick = async () => {
+ try {
+ await navigator.clipboard.writeText(link);
+ copy.textContent = "Link copied ✓";
+ } catch {
+ copy.textContent = "Select the link above to copy";
+ }
+ setTimeout(() => (copy.textContent = "Copy link"), 2000);
+ };
+ const done = document.createElement("button");
+ done.textContent = "Done";
+ done.onclick = () => backdrop.remove();
+ const row = document.createElement("div");
+ row.className = "row";
+ row.appendChild(copy);
+ // A passkey backup is the one-tap alternative to saving the link: enrol (or
+ // update) this instance with the home server. Only shown when a home server
+ // is configured — launchEnrol is a no-op otherwise.
+ if (window.__homeOrigin) {
+ const backup = document.createElement("button");
+ backup.className = "secondary";
+ backup.textContent = "Back up";
+ backup.onclick = () => {
+ backdrop.remove(); // web navigates same-tab; native/PWA opens a new context
+ launchEnrol(session);
+ };
+ row.appendChild(backup);
+ }
+ row.appendChild(done);
+ card.appendChild(h);
+ card.appendChild(note);
+ card.appendChild(qr);
+ card.appendChild(box);
+ card.appendChild(row);
+}
diff --git a/web/static/shell.js b/web/static/shell.js
index 3fa20bc..f00c56c 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -138,6 +138,7 @@ import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avata
import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js";
import { editGroup, openOrCreateDM, showMembers, viewProfile, updateRenameBanner, showSpaceInfo, closeChat, endConversation, toggleMute, togglePin, addPeople, manageGroupApps, leaveGroup } from "/static/groupactions.js";
import { existingDM, showNewChat, showMembership, flash, showDiscover, showApps, createThread } from "/static/newchat.js";
+import { addDevice } from "/static/adddevice.js";
import { verbs } from "/static/shellverbs.js"; // the behaviour seam (docs/shell-modules.md)
import { scheduleMessageSearch, wireSearch, searchIdx } from "/static/searchui.js"; // message search over the local store (#586)
import { loadMemberApps, setMemberSeeder, setSurfaceRefresh, setMemberAdmin, recentLocalDeploy, pushTheme } from "/static/sandbox.js"; // member-built apps, run sandboxed (docs/member-apps.md)
@@ -7375,89 +7376,6 @@ async function copyToButton(text, btn) {
setTimeout(() => (btn.textContent = label), 2000);
}
-// ---- add a device -------------------------------------------------------
-
-async function addDevice() {
- let link;
- try {
- const T = randomSecret();
- const { id, wrapKey, claimHash } = await deriveInvite(T);
- // Ship this user's keypair (so the new device can read every thread).
- const wrapped = await encrypt(wrapKey, { pub: session.userPub, priv: session.userPriv });
- const res = await fetch("/api/device-invites", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: session.token, id, wrapped, claim_hash: claimHash }),
- });
- if (!res.ok) throw new Error("server");
- // Neutral slug; the name rides in the #fragment (never sent to the server).
- link = `${location.origin}/join/join#${buildInviteFragment(T, session.name, brandingB || "")}`;
- } catch {
- return alert("Couldn't create a device link. Try again.");
- }
- const backdrop = modal();
- const card = backdrop.querySelector(".sheet");
- const h = document.createElement("h2");
- h.textContent = "Add a device or back up";
- const note = document.createElement("p");
- note.className = "sub";
- note.textContent = "Open this on another device to sign in there — or save it somewhere safe (like a password manager) as your backup: it's the only way back in if you lose this device. It works once, and anyone who uses it signs in as you.";
- const qr = document.createElement("div");
- qr.className = "qr-wrap";
- try {
- qr.appendChild(qrCanvas(link, { scale: 6, margin: 4 }));
- } catch {
- qr.textContent = "This link is too long to show as a QR code — use the link below.";
- }
- const box = document.createElement("div");
- box.className = "key-box mono";
- box.textContent = link;
- box.onclick = () => {
- const range = document.createRange();
- range.selectNodeContents(box);
- const sel = getSelection();
- sel.removeAllRanges();
- sel.addRange(range);
- };
- const copy = document.createElement("button");
- copy.className = "secondary";
- copy.textContent = "Copy link";
- copy.onclick = async () => {
- try {
- await navigator.clipboard.writeText(link);
- copy.textContent = "Link copied ✓";
- } catch {
- copy.textContent = "Select the link above to copy";
- }
- setTimeout(() => (copy.textContent = "Copy link"), 2000);
- };
- const done = document.createElement("button");
- done.textContent = "Done";
- done.onclick = () => backdrop.remove();
- const row = document.createElement("div");
- row.className = "row";
- row.appendChild(copy);
- // A passkey backup is the one-tap alternative to saving the link: enrol (or
- // update) this instance with the home server. Only shown when a home server
- // is configured — launchEnrol is a no-op otherwise.
- if (window.__homeOrigin) {
- const backup = document.createElement("button");
- backup.className = "secondary";
- backup.textContent = "Back up";
- backup.onclick = () => {
- backdrop.remove(); // web navigates same-tab; native/PWA opens a new context
- launchEnrol(session);
- };
- row.appendChild(backup);
- }
- row.appendChild(done);
- card.appendChild(h);
- card.appendChild(note);
- card.appendChild(qr);
- card.appendChild(box);
- card.appendChild(row);
-}
-
// ---- avatars + profile --------------------------------------------------
// A stable colour from a name, so each person's initial circle is consistent.