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