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>
3 files changed,
+94
−83
CLAUDE.md+2 −0web/static/adddevice.js+91 −0web/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. |