| 1 | // Add a device — mints a key-invite for this member and shows the sign-in |
| 2 | // link + QR (the fragment carries the one-time secret; the server never sees |
| 3 | // it). Extracted verbatim from shell.js. openInApp reuses the same mint to |
| 4 | // hand this space straight to the installed app (issue #1089's handoff |
| 5 | // cousin): an add-device link fired over the app scheme, claimed natively. |
| 6 | import { verbs } from "/static/shellverbs.js"; |
| 7 | import { session, brandingB } from "/static/shellstate.js"; |
| 8 | import { buildInviteFragment } from "/static/invite.js"; |
| 9 | import { deriveInvite, randomSecret, encrypt } from "/static/crypto.js"; |
| 10 | import { qrCanvas } from "/static/qrcode.js"; |
| 11 | import { launchEnrol } from "/static/backup.js"; |
| 12 | import { appSchemeFrom } from "/static/ui.js"; |
| 13 | |
| 14 | // ---- add a device ------------------------------------------------------- |
| 15 | |
| 16 | // Mint a one-time sign-in link for THIS member: the new device gets this |
| 17 | // user's keypair (wrapped under the fragment's secret — the server stores |
| 18 | // only the wrapped blob and a claim hash). null = couldn't reach the server. |
| 19 | async function mintDeviceLink() { |
| 20 | try { |
| 21 | const T = randomSecret(); |
| 22 | const { id, wrapKey, claimHash } = await deriveInvite(T); |
| 23 | // Ship this user's keypair (so the new device can read every thread). |
| 24 | const wrapped = await encrypt(wrapKey, { pub: session.userPub, priv: session.userPriv }); |
| 25 | const res = await fetch("/api/device-invites", { |
| 26 | method: "POST", |
| 27 | headers: { "Content-Type": "application/json" }, |
| 28 | body: JSON.stringify({ token: session.token, id, wrapped, claim_hash: claimHash }), |
| 29 | }); |
| 30 | if (!res.ok) throw new Error("server"); |
| 31 | // Neutral slug; the name rides in the #fragment (never sent to the server). |
| 32 | return `${location.origin}/join/join#${buildInviteFragment(T, session.name, brandingB || "")}`; |
| 33 | } catch { |
| 34 | return null; |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | export async function addDevice() { |
| 39 | const link = await mintDeviceLink(); |
| 40 | if (!link) return alert("Couldn't create a device link. Try again."); |
| 41 | const backdrop = verbs.modal(); |
| 42 | const card = backdrop.querySelector(".sheet"); |
| 43 | const h = document.createElement("h2"); |
| 44 | h.textContent = "Add a device"; |
| 45 | const note = document.createElement("p"); |
| 46 | note.className = "sub"; |
| 47 | note.textContent = "Open this on another device to sign in there — or save it somewhere safe (like a password manager): it's a way back in if you lose this device. It works once, and anyone who uses it signs in as you."; |
| 48 | const qr = document.createElement("div"); |
| 49 | qr.className = "qr-wrap"; |
| 50 | try { |
| 51 | qr.appendChild(qrCanvas(link, { scale: 6, margin: 4 })); |
| 52 | } catch { |
| 53 | qr.textContent = "This link is too long to show as a QR code — use the link below."; |
| 54 | } |
| 55 | const box = document.createElement("div"); |
| 56 | box.className = "key-box mono"; |
| 57 | box.textContent = link; |
| 58 | box.onclick = () => { |
| 59 | const range = document.createRange(); |
| 60 | range.selectNodeContents(box); |
| 61 | const sel = getSelection(); |
| 62 | sel.removeAllRanges(); |
| 63 | sel.addRange(range); |
| 64 | }; |
| 65 | const copy = document.createElement("button"); |
| 66 | copy.className = "secondary"; |
| 67 | copy.textContent = "Copy link"; |
| 68 | copy.onclick = async () => { |
| 69 | try { |
| 70 | await navigator.clipboard.writeText(link); |
| 71 | copy.textContent = "Link copied ✓"; |
| 72 | } catch { |
| 73 | copy.textContent = "Select the link above to copy"; |
| 74 | } |
| 75 | setTimeout(() => (copy.textContent = "Copy link"), 2000); |
| 76 | }; |
| 77 | const done = document.createElement("button"); |
| 78 | done.textContent = "Done"; |
| 79 | done.onclick = () => backdrop.remove(); |
| 80 | const row = document.createElement("div"); |
| 81 | row.className = "row"; |
| 82 | row.appendChild(copy); |
| 83 | // Signing in with Eleven is the one-tap alternative to saving the link: |
| 84 | // enrol (or update) this instance with the home server. Only shown when a |
| 85 | // home server is configured — launchEnrol is a no-op otherwise. |
| 86 | if (window.__homeOrigin) { |
| 87 | const backup = document.createElement("button"); |
| 88 | backup.className = "secondary"; |
| 89 | backup.textContent = "Sign in with Eleven"; |
| 90 | backup.onclick = () => { |
| 91 | backdrop.remove(); // web navigates same-tab; native/PWA opens a new context |
| 92 | launchEnrol(session); |
| 93 | }; |
| 94 | row.appendChild(backup); |
| 95 | } |
| 96 | row.appendChild(done); |
| 97 | card.appendChild(h); |
| 98 | card.appendChild(note); |
| 99 | card.appendChild(qr); |
| 100 | card.appendChild(box); |
| 101 | card.appendChild(row); |
| 102 | } |
| 103 | |
| 104 | // ---- open in the app ------------------------------------------------------ |
| 105 | |
| 106 | // The "Continue on web" regret path: hand this space to the installed app. |
| 107 | // Same one-time link as addDevice, fired over the app scheme instead of shown |
| 108 | // — the app pre-fills its Add-from-link confirm and this browser never shows |
| 109 | // the secret. A page can't know whether anything caught the scheme, so the |
| 110 | // sheet says what should have happened and keeps a copyable fallback. |
| 111 | export async function openInApp() { |
| 112 | const link = await mintDeviceLink(); |
| 113 | if (!link) return alert("Couldn't create a sign-in link. Try again."); |
| 114 | let scheme = "elevenmessenger"; |
| 115 | try { |
| 116 | scheme = appSchemeFrom(localStorage.getItem("lchat_app_scheme") || ""); |
| 117 | } catch { /* no storage — the shipped app */ } |
| 118 | // Assembled without a literal slash-pair: module-refs.test.mjs strips |
| 119 | // comments before strings, so "//" inside a string desyncs its scan. |
| 120 | location.href = scheme + ":" + "/".repeat(2) + "add?url=" + encodeURIComponent(link); |
| 121 | |
| 122 | const backdrop = verbs.modal(); |
| 123 | const card = backdrop.querySelector(".sheet"); |
| 124 | const h = document.createElement("h2"); |
| 125 | h.textContent = "Handing over to the Eleven app"; |
| 126 | const note = document.createElement("p"); |
| 127 | note.className = "sub"; |
| 128 | note.textContent = "The app opens with a one-time sign-in for this space — confirm it there and you're in as you. Nothing happened? You need the Eleven app (or its latest update); or copy the link below and paste it into the app's Add from link."; |
| 129 | const copy = document.createElement("button"); |
| 130 | copy.className = "secondary"; |
| 131 | copy.textContent = "Copy the sign-in link"; |
| 132 | copy.onclick = async () => { |
| 133 | try { |
| 134 | await navigator.clipboard.writeText(link); |
| 135 | copy.textContent = "Link copied ✓"; |
| 136 | } catch { |
| 137 | copy.textContent = "Couldn't copy — use Add a device instead"; |
| 138 | } |
| 139 | setTimeout(() => (copy.textContent = "Copy the sign-in link"), 2000); |
| 140 | }; |
| 141 | const done = document.createElement("button"); |
| 142 | done.textContent = "Done"; |
| 143 | done.onclick = () => backdrop.remove(); |
| 144 | const row = document.createElement("div"); |
| 145 | row.className = "row"; |
| 146 | row.appendChild(copy); |
| 147 | row.appendChild(done); |
| 148 | card.appendChild(h); |
| 149 | card.appendChild(note); |
| 150 | card.appendChild(row); |
| 151 | } |
| 152 | |