| 1 | // The join page (see docs/threads-and-dms.md). An invite link is /join/<label>#<T> |
| 2 | // (optionally #<T>~<name> for an add-device link). The fragment is never sent to |
| 3 | // the server. Joining sets up the user's keypair — generated fresh for a new |
| 4 | // person, or unwrapped from the link for an added device — registers the public |
| 5 | // key in the roster, claims a device token, and stores the session. |
| 6 | import { |
| 7 | decrypt, |
| 8 | decryptBytes, |
| 9 | deriveBrandingKey, |
| 10 | b64urlToBytes, |
| 11 | generateUserKeypair, |
| 12 | deriveInvite, |
| 13 | bindMac, |
| 14 | wrapThreadKey, |
| 15 | bytesToB64url, |
| 16 | } from "/static/crypto.js"; |
| 17 | import { parseInviteFragment } from "/static/invite.js"; |
| 18 | import { appSchemeFrom } from "/static/ui.js"; |
| 19 | import { PREJOIN_STASH_KEY, offerPrejoin, prejoinStash, readPrejoinReturn, homeSigninURL } from "/static/prejoin.js"; |
| 20 | import { INVITER_HINT_KEY, inviterHintStash } from "/static/invitehint.js"; |
| 21 | import { launchEnrol } from "/static/backup.js"; |
| 22 | |
| 23 | const SESSION_KEY = "lchat_session"; |
| 24 | const card = document.getElementById("card"); |
| 25 | const loader = document.getElementById("app-loading"); |
| 26 | |
| 27 | // Which app scheme "Open in the app" fires — ui.js's appSchemeFrom (the |
| 28 | // dev-alt override rides localStorage.lchat_app_scheme; see that helper). |
| 29 | function appScheme() { |
| 30 | try { |
| 31 | return appSchemeFrom(localStorage.getItem("lchat_app_scheme") || ""); |
| 32 | } catch { |
| 33 | return "elevenmessenger"; // no storage — the shipped app |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | // The ONE loading state: the same full-screen loader the app uses. It covers |
| 38 | // the page until a card has real content, and again while a claim is in |
| 39 | // flight — the card and the loader are never shown together. |
| 40 | function showLoader() { |
| 41 | if (loader) loader.classList.remove("hidden"); |
| 42 | card.classList.add("hidden"); |
| 43 | } |
| 44 | function showCard() { |
| 45 | if (loader) loader.classList.add("hidden"); |
| 46 | card.classList.remove("hidden"); |
| 47 | } |
| 48 | const isStandalone = |
| 49 | window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true; |
| 50 | |
| 51 | // The pre-join sign-in round trip returns here as /join/<label>?su=<nonce> |
| 52 | // with NO fragment — the invite secret parked in this origin's localStorage |
| 53 | // before leaving (it never travels to the home origin). Verify the nonce, |
| 54 | // recover the fragment (restoring it to the URL so "Open in the app" still |
| 55 | // hands over a complete link), and scrub the marker. |
| 56 | const prejoin = (() => { |
| 57 | let stored = ""; |
| 58 | try { stored = localStorage.getItem(PREJOIN_STASH_KEY) || ""; } catch {} |
| 59 | const r = readPrejoinReturn({ search: location.search, storedJSON: stored, now: Date.now() }); |
| 60 | if (r.signedUp) { |
| 61 | try { localStorage.removeItem(PREJOIN_STASH_KEY); } catch {} |
| 62 | try { |
| 63 | if (!location.hash && r.frag) history.replaceState(null, "", location.pathname + "#" + r.frag); |
| 64 | } catch {} |
| 65 | } |
| 66 | return r; |
| 67 | })(); |
| 68 | const { T, name: fragName, b: fragB } = parseInviteFragment(location.hash.slice(1) || prejoin.frag); |
| 69 | |
| 70 | // ?src=home marks a link opened from the person's Eleven sign-in page (the |
| 71 | // create-a-space flow). Remember it on this origin so the setup conversation's |
| 72 | // key step can say "add to your passkey" instead of pitching passkeys from |
| 73 | // scratch. A hint only — nothing downstream trusts it for access. |
| 74 | if (new URLSearchParams(location.search).get("src") === "home") { |
| 75 | try { localStorage.setItem("lchat_from_home", "1"); } catch { /* storage denied: the generic copy shows */ } |
| 76 | } |
| 77 | |
| 78 | // Single-fire guard for the claim: whatever path calls it (button, double tap, |
| 79 | // Enter), only the first attempt proceeds; the rest are ignored. |
| 80 | let claimStarted = false; |
| 81 | function onceOnly(fn) { |
| 82 | return (...a) => { |
| 83 | if (claimStarted) return; |
| 84 | claimStarted = true; |
| 85 | return fn(...a); |
| 86 | }; |
| 87 | } |
| 88 | |
| 89 | init(); |
| 90 | |
| 91 | function init() { |
| 92 | // Already installed? The link in this URL is likely spent — go to the app, |
| 93 | // which signs in via paste-to-join. |
| 94 | if (isStandalone && !window.__lchatEmbed) return location.replace("/"); |
| 95 | if (!T) return showError("This link is missing its secret.", { signin: true }); |
| 96 | start(); |
| 97 | } |
| 98 | |
| 99 | // The claim secret derived from T (proves knowledge of the invite secret when |
| 100 | // claiming/adopting). Set once in start(); read by finish()/adoptExisting(). |
| 101 | let inviteClaimSecret = ""; |
| 102 | // The key-transparency binding key derived from T (#573): the joiner MACs its |
| 103 | // published public key under it so the inviter (a T-holder) can verify the |
| 104 | // roster key wasn't substituted. Set in start(), read by finish(). |
| 105 | let inviteBindKey = ""; |
| 106 | // The inviter's display name from the sealed payload (#1436), stashed at claim |
| 107 | // time so the app labels them before the instance key arrives. Set in start(). |
| 108 | let inviterFrom = ""; |
| 109 | |
| 110 | // Whether this instance has a branding photo. Fetched in start() and shown on |
| 111 | // the onboarding cards in place of the generic emoji. On a sealed instance |
| 112 | // (docs/no-plaintext.md) the photo is ciphertext: serverPhotoURL becomes a |
| 113 | // decrypted blob: URL when the link carries the branding key, else the photo |
| 114 | // (and the readable name/description) simply doesn't show — the link still |
| 115 | // claims fine. See docs/onboarding-and-home-server.md. |
| 116 | let serverHasAvatar = false; |
| 117 | let serverAvatarAlt = ""; |
| 118 | let serverPhotoURL = "/api/server-avatar"; |
| 119 | // The sealed branding name, when this link's fragment can open it — the lead |
| 120 | // card's h1 was server-baked with the generic fallback and needs the real name. |
| 121 | let brandingName = ""; |
| 122 | |
| 123 | async function start() { |
| 124 | let id, wrapKey, info; |
| 125 | try { |
| 126 | const derived = await deriveInvite(T); |
| 127 | id = derived.id; |
| 128 | wrapKey = derived.wrapKey; |
| 129 | inviteClaimSecret = derived.claimSecret; |
| 130 | inviteBindKey = derived.bindKey; |
| 131 | info = await (await fetch(`/api/key-invite/${encodeURIComponent(id)}`)).json(); |
| 132 | } catch { |
| 133 | return showError("Couldn't reach the server. Try again."); |
| 134 | } |
| 135 | if (!info.ok) { |
| 136 | // A dead link in an existing member's hands is just a stale bookmark — |
| 137 | // they're already in, so go to the chat instead of an error card telling |
| 138 | // them to ask for an invite they don't need. liveSession (not a raw |
| 139 | // localStorage read) so a token from a reset instance still falls through |
| 140 | // to the honest error. Embed keeps the error: the native add-chat webview |
| 141 | // owns navigation, and its shell expects a join, not a redirect. |
| 142 | const existing = window.__lchatEmbed ? null : await liveSession(); |
| 143 | if (existing) return location.replace("/"); |
| 144 | return showError("This invite has been used or is no longer valid. Ask for a new one.", { signin: true }); |
| 145 | } |
| 146 | |
| 147 | // The space's description rides on the invite lookup (holders of a valid link |
| 148 | // only — it's never on the public read), so the welcome card can say what |
| 149 | // this place is before the joiner holds any key. |
| 150 | let spaceDesc = typeof info.description === "string" ? info.description : ""; |
| 151 | // A cohort invite names the access level the joiner lands in (plaintext by |
| 152 | // design, like the description — served to link-holders only). |
| 153 | cohortName = typeof info.tier_name === "string" ? info.tier_name : ""; |
| 154 | |
| 155 | // Note whether this instance has a branding photo (a public flag), so the |
| 156 | // onboarding cards below can show it instead of the placeholder emoji. |
| 157 | let groupInfo = {}; |
| 158 | try { |
| 159 | groupInfo = await (await fetch("/api/group")).json(); |
| 160 | serverHasAvatar = !!groupInfo.avatar; |
| 161 | serverAvatarAlt = groupInfo.avatar_alt || ""; |
| 162 | } catch {} |
| 163 | |
| 164 | // Sealed branding: this link's fragment carries the branding key, so open |
| 165 | // the blob and render the real name/description/photo in place of the |
| 166 | // defaults. A link without the key (minted before sealing) still claims — |
| 167 | // the card just stays generic. Decrypt failures are treated the same way. |
| 168 | if (info.branding_blob && info.branding_salt && fragB) { |
| 169 | try { |
| 170 | const bKey = await deriveBrandingKey(fragB, info.branding_salt); |
| 171 | const branding = await decrypt(bKey, info.branding_blob); |
| 172 | if (branding.name) { |
| 173 | brandingName = branding.name; |
| 174 | document.title = branding.name; |
| 175 | // The h1 was server-baked with the generic fallback (the server can't |
| 176 | // read the sealed name); the standard join card never re-sets it, so |
| 177 | // fill it here. The device/adopt/admin kinds overwrite it later. |
| 178 | const h1 = document.querySelector("#welcome-title"); |
| 179 | if (h1) h1.textContent = `You're invited to ${branding.name}`; |
| 180 | } |
| 181 | if (branding.description) spaceDesc = branding.description; |
| 182 | serverAvatarAlt = branding.avatar_alt || serverAvatarAlt; |
| 183 | // Post-claim, the app bootstraps the branding key from here (it also |
| 184 | // has the instance-key-sealed copy as a fallback). Stash it before the |
| 185 | // photo work below: a failed photo decrypt must not cost the handoff. |
| 186 | try { localStorage.setItem("lchat_branding_b", fragB); } catch {} |
| 187 | if (serverHasAvatar && groupInfo.sealed) { |
| 188 | try { |
| 189 | const sealedBytes = await (await fetch("/api/server-avatar")).text(); |
| 190 | const photo = await decryptBytes(bKey, b64urlToBytes(sealedBytes)); |
| 191 | serverPhotoURL = URL.createObjectURL(new Blob([photo], { type: branding.avatar_mime || "image/jpeg" })); |
| 192 | } catch { |
| 193 | serverHasAvatar = false; // ciphertext isn't an image — show the placeholder |
| 194 | } |
| 195 | } |
| 196 | } catch { /* stay generic */ } |
| 197 | } else if (groupInfo.sealed && serverHasAvatar) { |
| 198 | // Sealed photo but no key in this link: the ciphertext isn't an image. |
| 199 | serverHasAvatar = false; |
| 200 | } |
| 201 | |
| 202 | // Everything below only *decides* what continuing means for this link — the |
| 203 | // claim itself waits for an explicit choice on the welcome card. Claiming on |
| 204 | // page-load burned the one-time secret in whatever context rendered the link |
| 205 | // first (a hidden webview, a second tab) — see docs/onboarding-and-home-server.md. |
| 206 | let payload = null; |
| 207 | if (info.wrapped) { |
| 208 | try { |
| 209 | payload = await decrypt(wrapKey, info.wrapped); |
| 210 | } catch { |
| 211 | return showError("Couldn't unlock this link (wrong or corrupt secret).", { signin: true }); |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | if (payload && payload.priv) { |
| 216 | // Add-device link: it carries this user's keypair (and name) — signing in |
| 217 | // is the only possible action, but it still waits for the click. |
| 218 | return showWelcome("device", () => { |
| 219 | showStatus("Signing you in…"); |
| 220 | finish(id, fragName || "Me", payload.pub, payload.priv); |
| 221 | }); |
| 222 | } |
| 223 | if (payload && payload.threadKey) { |
| 224 | // The inviter's display name rides the sealed payload (#1436): remember it |
| 225 | // so the app's first paint can label them before the instance key lands. |
| 226 | inviterFrom = typeof payload.from === "string" ? payload.from.trim().slice(0, 64) : ""; |
| 227 | // User invite: it drops the joiner into a DM with the person who invited them. |
| 228 | // In the native embed the shell owns identities (Keychain) — never adopt a |
| 229 | // stray webview session; always run the fresh-person flow and hand back. |
| 230 | const existing = window.__lchatEmbed ? null : await liveSession(); |
| 231 | if (existing && existing.token && existing.userPub && existing.userPriv) { |
| 232 | // Already a member on this device → join that DM as your existing self. |
| 233 | return showWelcome("adopt", () => { |
| 234 | showStatus("Opening your chat…"); |
| 235 | adoptExisting(id, payload.threadKey, existing); |
| 236 | }); |
| 237 | } |
| 238 | // New person: chat-first — claim nameless and let Eleven's welcome |
| 239 | // conversation ask the name in-chat (the inviter's suggestion rides along |
| 240 | // in the pending marker). The native add-chat webview keeps the form: it |
| 241 | // hands a NAMED identity straight back to the shell. |
| 242 | if (window.__lchatEmbed) { |
| 243 | return showWelcome( |
| 244 | "join", |
| 245 | () => |
| 246 | showForm(fragName, async (name) => { |
| 247 | showStatus("Setting up…"); |
| 248 | const kp = await generateUserKeypair(); |
| 249 | const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey); |
| 250 | finish(id, name, kp.publicKey, kp.privateKey, wrappedSelf); |
| 251 | }), |
| 252 | spaceDesc, |
| 253 | ); |
| 254 | } |
| 255 | return showWelcome( |
| 256 | "join", |
| 257 | async () => { |
| 258 | showStatus("Setting up…"); |
| 259 | const kp = await generateUserKeypair(); |
| 260 | const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey); |
| 261 | markWelcomePending(fragName); |
| 262 | finish(id, "", kp.publicKey, kp.privateKey, wrappedSelf); |
| 263 | }, |
| 264 | spaceDesc, |
| 265 | ); |
| 266 | } |
| 267 | |
| 268 | // Setup invite (admin-granting): if already logged in, promote the existing |
| 269 | // account. Otherwise, create a new admin account. |
| 270 | if (info.grants_admin) { |
| 271 | const existing = window.__lchatEmbed ? null : await liveSession(); |
| 272 | if (existing && existing.token && existing.userPub && existing.userPriv) { |
| 273 | // Already a member: promote to admin on existing account. |
| 274 | return showWelcome("adopt-admin", () => { |
| 275 | showStatus("Promoting to admin…"); |
| 276 | adoptAdminExisting(id, existing); |
| 277 | }); |
| 278 | } |
| 279 | // New person becoming admin: check if this instance's setup is already complete. |
| 280 | // If so, skip the "setup" flow label and just proceed normally — the app won't |
| 281 | // trigger setup conversation if setup_complete is true. |
| 282 | let setupNeeded = true; |
| 283 | try { |
| 284 | const me = await (await fetch("/api/me")).json(); |
| 285 | setupNeeded = !me.setup_complete; |
| 286 | } catch {} |
| 287 | const setupLabel = setupNeeded ? "setup" : "join"; |
| 288 | return showWelcome(setupLabel, async () => { |
| 289 | showStatus("Setting up…"); |
| 290 | const kp = await generateUserKeypair(); |
| 291 | finish(id, "", kp.publicKey, kp.privateKey); |
| 292 | }); |
| 293 | } |
| 294 | |
| 295 | // New person (or unknown payload shape): if already logged in, adopt this |
| 296 | // invite as your existing self. Otherwise, start a new account. |
| 297 | const existing = window.__lchatEmbed ? null : await liveSession(); |
| 298 | if (existing && existing.token && existing.userPub && existing.userPriv) { |
| 299 | // Already a member: adopt this invite as your existing self (no wrapped key needed). |
| 300 | return showWelcome("adopt", () => { |
| 301 | showStatus("Opening your chat…"); |
| 302 | adoptInviteExisting(id, existing); |
| 303 | }); |
| 304 | } |
| 305 | // New person: same chat-first dance — the name is Eleven's first question, |
| 306 | // not a form (native add-chat keeps its form). |
| 307 | if (window.__lchatEmbed) { |
| 308 | return showWelcome( |
| 309 | "join", |
| 310 | () => |
| 311 | showForm(fragName, async (name) => { |
| 312 | showStatus("Setting up…"); |
| 313 | const kp = await generateUserKeypair(); |
| 314 | finish(id, name, kp.publicKey, kp.privateKey); |
| 315 | }), |
| 316 | spaceDesc, |
| 317 | ); |
| 318 | } |
| 319 | showWelcome( |
| 320 | "join", |
| 321 | async () => { |
| 322 | showStatus("Setting up…"); |
| 323 | const kp = await generateUserKeypair(); |
| 324 | markWelcomePending(fragName); |
| 325 | finish(id, "", kp.publicKey, kp.privateKey); |
| 326 | }, |
| 327 | spaceDesc, |
| 328 | ); |
| 329 | } |
| 330 | |
| 331 | // The pending marker hands the joiner to Eleven's welcome conversation on the |
| 332 | // app's first load (shell.js maybeStartWelcome), carrying the inviter's |
| 333 | // suggested name. Local-only — the name suggestion never touches the server. |
| 334 | // A new invite pasted into an already-open join tab changes only the URL |
| 335 | // fragment — a same-document navigation, so nothing re-runs and the old |
| 336 | // card (or error) just sits there. Reload to process the new link. |
| 337 | addEventListener("hashchange", () => location.reload()); |
| 338 | |
| 339 | function markWelcomePending(suggested) { |
| 340 | try { |
| 341 | localStorage.setItem("lchat_welcome_pending", JSON.stringify({ suggested: suggested || "" })); |
| 342 | } catch {} |
| 343 | } |
| 344 | |
| 345 | // The welcome card: the invite is inspected (a non-consuming GET) but nothing is |
| 346 | // claimed until a button is pressed. In the native add-chat flow (__lchatEmbed) |
| 347 | // the person already chose explicitly in the app, so continue straight on. |
| 348 | let cohortName = ""; // cohort invite: the level the joiner will be part of |
| 349 | |
| 350 | // `spaceDesc` is the space's admin-set description (from the invite lookup). |
| 351 | function showWelcome(kind, onContinue, spaceDesc = "", opts = {}) { |
| 352 | if (window.__lchatEmbed) return onContinue(); |
| 353 | // Signup-first: a fresh joiner leads with the sign-in door, not the app/web |
| 354 | // choice (skippable; established identities and native contexts never see it). |
| 355 | if (!opts.skipPrejoin && offerPrejoin({ |
| 356 | kind, |
| 357 | homeOrigin: window.__homeOrigin || "", |
| 358 | embed: !!window.__lchatEmbed, |
| 359 | native: document.documentElement.classList.contains("native"), |
| 360 | signedUp: prejoin.signedUp, |
| 361 | })) { |
| 362 | return showPrejoin(kind, onContinue, spaceDesc); |
| 363 | } |
| 364 | card.classList.add("onboard"); |
| 365 | card.classList.remove("status"); |
| 366 | card.innerHTML = ""; |
| 367 | showCard(); |
| 368 | const tpl = document.getElementById("welcome-tpl"); |
| 369 | card.appendChild(tpl.content.cloneNode(true)); |
| 370 | applyServerPhoto(card); |
| 371 | if (spaceDesc) { |
| 372 | const d = card.querySelector("#welcome-desc"); |
| 373 | d.textContent = spaceDesc.slice(0, 600); |
| 374 | d.classList.remove("hidden"); |
| 375 | } |
| 376 | if (cohortName && kind !== "device") { |
| 377 | // The cohort line sits under the subtitle: arriving somewhere and being |
| 378 | // told which circle you're joining is half the welcome. |
| 379 | const c = document.createElement("p"); |
| 380 | c.className = "sub"; |
| 381 | c.textContent = `You'll be part of ${cohortName}.`.slice(0, 120); |
| 382 | const sub = card.querySelector("#welcome-sub"); |
| 383 | if (sub) sub.after(c); |
| 384 | } |
| 385 | // Back from the pre-join sign-in: the browser-only caveat is no longer true — |
| 386 | // their access rides their account (finish() saves the session to it). |
| 387 | if (prejoin.signedUp) { |
| 388 | const cav = card.querySelector("#welcome-caveat"); |
| 389 | if (cav) cav.textContent = |
| 390 | "Your access will be saved to your Eleven account — you can get back in from any device."; |
| 391 | } |
| 392 | if (kind === "device") { |
| 393 | // document.title carries the substituted group name. |
| 394 | card.querySelector("#welcome-title").textContent = `Sign in to ${document.title} on this device`; |
| 395 | card.querySelector("#welcome-sub").textContent = "This link signs this device in as you."; |
| 396 | card.querySelector("#welcome-caveat").remove(); |
| 397 | card.querySelector("#welcome-web").textContent = "Sign in here in the browser"; |
| 398 | } else if (kind === "adopt") { |
| 399 | const groupName = document.title.replace("You're invited to ", ""); |
| 400 | card.querySelector("#welcome-title").textContent = `You're already in ${groupName}`; |
| 401 | card.querySelector("#welcome-sub").textContent = |
| 402 | "Continue to open the new conversation on this device."; |
| 403 | card.querySelector("#welcome-caveat").remove(); |
| 404 | card.querySelector("#welcome-web").textContent = "Continue"; |
| 405 | } else if (kind === "adopt-admin") { |
| 406 | card.querySelector("#welcome-title").textContent = "Become admin"; |
| 407 | card.querySelector("#welcome-sub").textContent = |
| 408 | "You're already a member on this device. Continue to accept the admin role."; |
| 409 | card.querySelector("#welcome-caveat").remove(); |
| 410 | card.querySelector("#welcome-web").textContent = "Accept"; |
| 411 | } else if (kind === "setup") { |
| 412 | card.querySelector("#welcome-title").textContent = "Welcome to your new space"; |
| 413 | card.querySelector("#welcome-sub").textContent = |
| 414 | "This one-time link makes you its administrator. Continue, and we'll set things up together."; |
| 415 | card.querySelector("#welcome-caveat").remove(); |
| 416 | card.querySelector("#welcome-web").textContent = "Let's get started"; |
| 417 | } |
| 418 | // "Open in the app" hands the full link — fragment included — to the installed |
| 419 | // app via its scheme; the scheme URL never reaches any server. There's no way |
| 420 | // to detect the app from here: if nothing claims the scheme this is a no-op |
| 421 | // and the other buttons stand. Inside the Mac app's own webview it's noise. |
| 422 | const appBtn = card.querySelector("#welcome-app"); |
| 423 | if (document.documentElement.classList.contains("native")) { |
| 424 | appBtn.closest(".row").remove(); |
| 425 | } else { |
| 426 | appBtn.onclick = () => { |
| 427 | location.href = appScheme() + "://add?url=" + encodeURIComponent(location.href); |
| 428 | }; |
| 429 | } |
| 430 | // "Get the app" is per-instance config (LCHAT_APP_STORE_URL); hidden until set. |
| 431 | const storeURL = tpl.dataset.storeUrl || ""; |
| 432 | const storeBtn = card.querySelector("#welcome-store"); |
| 433 | if (storeURL) storeBtn.onclick = () => window.open(storeURL, "_blank", "noopener"); |
| 434 | else storeBtn.closest(".row").remove(); |
| 435 | const webBtn = card.querySelector("#welcome-web"); |
| 436 | const go = onceOnly(onContinue); |
| 437 | webBtn.onclick = () => { |
| 438 | webBtn.disabled = true; // instant feedback — no dead-feeling first tap |
| 439 | go(); |
| 440 | }; |
| 441 | } |
| 442 | |
| 443 | // The signup-first lead card: sign in with Eleven BEFORE anything is claimed. |
| 444 | // The tap parks the invite fragment (and a fresh nonce) on THIS origin and |
| 445 | // heads to the home server's doors; home returns to /join/<label>?su=<nonce>. |
| 446 | // Skipping falls through to the classic welcome card — zero-friction stays. |
| 447 | function showPrejoin(kind, onContinue, spaceDesc) { |
| 448 | card.classList.add("onboard"); |
| 449 | card.classList.remove("status"); |
| 450 | card.innerHTML = ""; |
| 451 | showCard(); |
| 452 | card.appendChild(document.getElementById("prejoin-tpl").content.cloneNode(true)); |
| 453 | applyServerPhoto(card); |
| 454 | // The h1 was server-baked with the generic fallback on sealed instances. |
| 455 | if (brandingName) card.querySelector("#prejoin-title").textContent = `You're invited to ${brandingName}`; |
| 456 | if (kind === "setup") { |
| 457 | card.querySelector("#prejoin-title").textContent = "Welcome to your new space"; |
| 458 | card.querySelector("#prejoin-sub").textContent = |
| 459 | "This one-time link makes you its administrator. Sign in first, and your access is saved from the start."; |
| 460 | } |
| 461 | if (spaceDesc) { |
| 462 | const d = card.querySelector("#prejoin-desc"); |
| 463 | d.textContent = spaceDesc.slice(0, 600); |
| 464 | d.classList.remove("hidden"); |
| 465 | } |
| 466 | if (cohortName) { |
| 467 | const c = document.createElement("p"); |
| 468 | c.className = "sub"; |
| 469 | c.textContent = `You'll be part of ${cohortName}.`.slice(0, 120); |
| 470 | card.querySelector("#prejoin-sub").after(c); |
| 471 | } |
| 472 | card.querySelector("#prejoin-signin").onclick = () => { |
| 473 | const b = new Uint8Array(16); |
| 474 | crypto.getRandomValues(b); |
| 475 | const nonce = bytesToB64url(b); |
| 476 | try { |
| 477 | localStorage.setItem(PREJOIN_STASH_KEY, prejoinStash({ frag: location.hash.slice(1), nonce, now: Date.now() })); |
| 478 | } catch { |
| 479 | // No storage: the fragment can't survive the round trip — fall through to |
| 480 | // the classic card rather than strand the secret. |
| 481 | return showWelcome(kind, onContinue, spaceDesc, { skipPrejoin: true }); |
| 482 | } |
| 483 | location.assign(homeSigninURL(window.__homeOrigin, location.origin, location.pathname, nonce)); |
| 484 | }; |
| 485 | card.querySelector("#prejoin-skip").onclick = () => |
| 486 | showWelcome(kind, onContinue, spaceDesc, { skipPrejoin: true }); |
| 487 | } |
| 488 | |
| 489 | async function finish(id, name, pub, priv, wrappedSelf = "") { |
| 490 | let claim; |
| 491 | try { |
| 492 | claim = await ( |
| 493 | await fetch(`/api/key-invite/${encodeURIComponent(id)}/claim`, { |
| 494 | method: "POST", |
| 495 | headers: { "Content-Type": "application/json" }, |
| 496 | // The screen name is never sent to the server — it's published encrypted |
| 497 | // (POST /api/label) once the app loads and has the instance key. |
| 498 | body: JSON.stringify({ wrapped: wrappedSelf, claim_secret: inviteClaimSecret }), |
| 499 | }) |
| 500 | ).json(); |
| 501 | } catch { |
| 502 | localStorage.removeItem("lchat_welcome_pending"); // the claim didn't take — drop the marker |
| 503 | return showError("Couldn't reach the server. Try again."); |
| 504 | } |
| 505 | if (!claim.ok || !claim.token) { |
| 506 | localStorage.removeItem("lchat_welcome_pending"); |
| 507 | return showError("This invite was just used. Ask for a new one."); |
| 508 | } |
| 509 | // Key transparency (#573): MAC our public key under the T-derived bind key so |
| 510 | // the inviter can verify it's the key WE published. Computed OUTSIDE the |
| 511 | // publish try — a bind-MAC hiccup must never skip the load-bearing pubkey |
| 512 | // registration (that would leave a claimed-but-keyless broken join). |
| 513 | let bindMacVal = ""; |
| 514 | try { |
| 515 | if (inviteBindKey) bindMacVal = await bindMac(inviteBindKey, pub); |
| 516 | } catch {} |
| 517 | try { |
| 518 | await fetch("/api/pubkey", { |
| 519 | method: "POST", |
| 520 | headers: { "Content-Type": "application/json" }, |
| 521 | body: JSON.stringify({ token: claim.token, public_key: pub, bind_mac: bindMacVal }), |
| 522 | }); |
| 523 | } catch {} |
| 524 | if (!window.__lchatEmbed) { |
| 525 | localStorage.setItem(SESSION_KEY, JSON.stringify({ token: claim.token, name, userPub: pub, userPriv: priv })); |
| 526 | // Land straight in the DM this invite created (the app opens it on boot). |
| 527 | if (claim.thread_id) localStorage.setItem("lchat_open_thread", claim.thread_id); |
| 528 | // First-paint label for the inviter (#1436) — provisional until their real |
| 529 | // label decrypts; shell.js applies and clears it. |
| 530 | if (claim.thread_id && inviterFrom) { |
| 531 | try { |
| 532 | localStorage.setItem(INVITER_HINT_KEY, inviterHintStash({ thread: claim.thread_id, name: inviterFrom, now: Date.now() })); |
| 533 | } catch {} |
| 534 | } |
| 535 | } |
| 536 | if (window.__lchatEmbed) { |
| 537 | // Native add-instance flow: hand the resolved identity back to the shell, which |
| 538 | // moves the private key into the Keychain. Don't navigate — the shell tears the |
| 539 | // one-shot join webview down once it has this. |
| 540 | try { |
| 541 | window.webkit.messageHandlers.lchat.postMessage({ |
| 542 | joined: { token: claim.token, name, userPub: pub, userPriv: priv }, |
| 543 | }); |
| 544 | } catch {} |
| 545 | return; |
| 546 | } |
| 547 | // Signed up before joining: save this brand-new session to the account NOW, |
| 548 | // via the /enroll round trip. The home-tab session from the sign-in moments |
| 549 | // ago is still live, so enrolment completes hands-free (payload.auto) and |
| 550 | // lands back at "/" with the standard nonce marker — the joiner arrives in |
| 551 | // the chat already backed up, and Eleven never has to ask. |
| 552 | if (prejoin.signedUp && (window.__homeOrigin || "").startsWith("http")) { |
| 553 | return launchEnrol({ token: claim.token, name, userPub: pub, userPriv: priv }, { auto: true }); |
| 554 | } |
| 555 | await plugIn(); |
| 556 | location.href = "/"; // drop the fragment from the URL bar |
| 557 | } |
| 558 | |
| 559 | // adoptExisting joins the invite's pending DM as the member already signed in on |
| 560 | // this device (rather than creating a new person). We wrap the DM key to our |
| 561 | // existing public key and let the server swap us in for the placeholder. |
| 562 | async function adoptExisting(id, threadKeyB64, existing) { |
| 563 | try { |
| 564 | const wrapped = await wrapThreadKey(threadKeyB64, existing.userPub); |
| 565 | const r = await ( |
| 566 | await fetch(`/api/key-invite/${encodeURIComponent(id)}/adopt`, { |
| 567 | method: "POST", |
| 568 | headers: { "Content-Type": "application/json" }, |
| 569 | body: JSON.stringify({ token: existing.token, wrapped, claim_secret: inviteClaimSecret }), |
| 570 | }) |
| 571 | ).json(); |
| 572 | if (!r.ok) { |
| 573 | return showError("This invite has been used or is no longer valid. Ask for a new one."); |
| 574 | } |
| 575 | if (r.thread_id) localStorage.setItem("lchat_open_thread", r.thread_id); |
| 576 | await plugIn(); |
| 577 | location.href = "/"; |
| 578 | } catch { |
| 579 | showError("Couldn't reach the server. Try again."); |
| 580 | } |
| 581 | } |
| 582 | |
| 583 | // adoptAdminExisting claims an admin-granting invite on an existing account, |
| 584 | // promoting the existing member to admin without creating a new user account. |
| 585 | async function adoptAdminExisting(id, existing) { |
| 586 | try { |
| 587 | const r = await ( |
| 588 | await fetch(`/api/key-invite/${encodeURIComponent(id)}/adopt`, { |
| 589 | method: "POST", |
| 590 | headers: { "Content-Type": "application/json" }, |
| 591 | body: JSON.stringify({ token: existing.token, wrapped: "", claim_secret: inviteClaimSecret }), |
| 592 | }) |
| 593 | ).json(); |
| 594 | if (!r.ok) { |
| 595 | return showError("This invite has been used or is no longer valid. Ask for a new one."); |
| 596 | } |
| 597 | // Token stays the same (existing session is preserved). Just redirect back to app. |
| 598 | await plugIn(); |
| 599 | location.href = "/"; |
| 600 | } catch { |
| 601 | showError("Couldn't reach the server. Try again."); |
| 602 | } |
| 603 | } |
| 604 | |
| 605 | // adoptInviteExisting claims a generic invite (no wrapped payload) on an existing |
| 606 | // account, letting them adopt it as their existing self without creating a new user. |
| 607 | async function adoptInviteExisting(id, existing) { |
| 608 | try { |
| 609 | const r = await ( |
| 610 | await fetch(`/api/key-invite/${encodeURIComponent(id)}/adopt`, { |
| 611 | method: "POST", |
| 612 | headers: { "Content-Type": "application/json" }, |
| 613 | body: JSON.stringify({ token: existing.token, wrapped: "", claim_secret: inviteClaimSecret }), |
| 614 | }) |
| 615 | ).json(); |
| 616 | if (!r.ok) { |
| 617 | return showError("This invite has been used or is no longer valid. Ask for a new one."); |
| 618 | } |
| 619 | if (r.thread_id) localStorage.setItem("lchat_open_thread", r.thread_id); |
| 620 | await plugIn(); |
| 621 | location.href = "/"; |
| 622 | } catch { |
| 623 | showError("Couldn't reach the server. Try again."); |
| 624 | } |
| 625 | } |
| 626 | |
| 627 | // liveSession: loadSession, but verified against the server. A stored session |
| 628 | // can outlive its account — an instance reset (or this device's removal) |
| 629 | // leaves a token the server no longer knows, and trusting it routes a fresh |
| 630 | // invite down the "you're already in" adopt path, which then dies with a |
| 631 | // misleading "invite no longer valid". Drop the dead session and fall through |
| 632 | // to the fresh-person flow ONLY on a definitive 403 — a network failure keeps |
| 633 | // the session (dropping keys because wifi blipped would be catastrophic for a |
| 634 | // real member), and the adopt path then fails loudly as before. |
| 635 | async function liveSession() { |
| 636 | const existing = loadSession(); |
| 637 | if (!existing || !existing.token || !existing.userPub || !existing.userPriv) return null; |
| 638 | try { |
| 639 | const r = await fetch(`/api/me?token=${encodeURIComponent(existing.token)}`); |
| 640 | if (r.status === 403) { |
| 641 | localStorage.removeItem(SESSION_KEY); |
| 642 | return null; |
| 643 | } |
| 644 | } catch {} |
| 645 | return existing; |
| 646 | } |
| 647 | |
| 648 | function loadSession() { |
| 649 | try { |
| 650 | return JSON.parse(localStorage.getItem(SESSION_KEY) || "null"); |
| 651 | } catch { |
| 652 | return null; |
| 653 | } |
| 654 | } |
| 655 | |
| 656 | // ---- views -------------------------------------------------------------- |
| 657 | |
| 658 | // Reveal the instance branding photo on a freshly-rendered onboarding card (and |
| 659 | // hide the placeholder emoji) when one is set. The <img> src is our own |
| 660 | // same-origin endpoint — never fragment or user input. |
| 661 | function applyServerPhoto(root) { |
| 662 | if (!serverHasAvatar) return; |
| 663 | const img = root.querySelector(".onboard-photo"); |
| 664 | if (!img) return; |
| 665 | img.src = serverPhotoURL; // same-origin endpoint, or a decrypted blob: URL when sealed |
| 666 | img.alt = serverAvatarAlt; // the admin's alt text travels with the photo |
| 667 | img.classList.remove("hidden"); |
| 668 | const emoji = root.querySelector(".onboard-icon"); |
| 669 | if (emoji) emoji.classList.add("hidden"); |
| 670 | } |
| 671 | |
| 672 | function showForm(defaultName, onSubmit) { |
| 673 | card.classList.remove("onboard", "status"); |
| 674 | card.innerHTML = ""; |
| 675 | card.appendChild(document.getElementById("form-tpl").content.cloneNode(true)); |
| 676 | showCard(); |
| 677 | applyServerPhoto(card); |
| 678 | const input = card.querySelector("#name"); |
| 679 | if (defaultName) input.value = defaultName; |
| 680 | input.focus(); |
| 681 | input.select(); |
| 682 | card.querySelector("#join-form").onsubmit = (e) => { |
| 683 | e.preventDefault(); |
| 684 | const name = input.value.trim(); |
| 685 | if (name) onSubmit(name); |
| 686 | }; |
| 687 | } |
| 688 | |
| 689 | // In-flight states ("Setting up…", "Signing you in…") all render as the one |
| 690 | // full-screen loader — the message argument is accepted for call-site clarity |
| 691 | // but the loader speaks for itself. |
| 692 | function showStatus(_msg) { |
| 693 | showLoader(); |
| 694 | } |
| 695 | |
| 696 | // The beat between "claimed" and the app: flash "Plugging in…" under the |
| 697 | // spinner, fade it out, then navigate — the amp knob fades in on the far side. |
| 698 | async function plugIn() { |
| 699 | if (!loader) return; |
| 700 | const msg = document.createElement("div"); |
| 701 | msg.className = "plugging-in"; |
| 702 | msg.textContent = "Plugging in…"; |
| 703 | loader.appendChild(msg); |
| 704 | requestAnimationFrame(() => msg.classList.add("show")); |
| 705 | await new Promise((r) => setTimeout(r, 1000)); |
| 706 | msg.classList.remove("show"); |
| 707 | await new Promise((r) => setTimeout(r, 300)); |
| 708 | } |
| 709 | |
| 710 | function showError(msg, opts = {}) { |
| 711 | card.classList.remove("onboard", "status"); |
| 712 | card.innerHTML = ""; |
| 713 | card.appendChild(document.getElementById("error-tpl").content.cloneNode(true)); |
| 714 | card.querySelector("#error-msg").textContent = msg; |
| 715 | // A dead invite can be a returning member's stale bookmark on a device whose |
| 716 | // session is gone — they don't need a new invite, they need the sign-in door |
| 717 | // (their account restores their spaces from home). Opt-in per call site so |
| 718 | // unrelated errors ("couldn't reach the server") don't grow a misleading CTA. |
| 719 | // The startsWith guard also covers a dev page served without substitution. |
| 720 | const home = window.__homeOrigin || ""; |
| 721 | if (opts.signin && !window.__lchatEmbed && home.startsWith("http")) { |
| 722 | const btn = card.querySelector("#error-signin"); |
| 723 | btn.classList.remove("hidden"); |
| 724 | btn.onclick = () => { location.href = home; }; |
| 725 | } |
| 726 | showCard(); |
| 727 | } |
| 728 | |
| 729 | |