| 1 | // The home landing page: every sign-in method is a door into the same |
| 2 | // anonymous account, and the card composes itself per browser (#1112, as |
| 3 | // revised by Paul 2026-07-19): exactly ONE door — the last-used method when |
| 4 | // this browser holds one, else the platform default — then a quiet "Other |
| 5 | // ways to sign in" link to the all-ways step, which lists every door |
| 6 | // (email included; nothing else ever sits on the main card). Apple and |
| 7 | // email are find-or-create (one button resolves your account or mints it, |
| 8 | // no sign-in vs sign-up split). |
| 9 | // Create-a-passkey appears only after a failed ceremony (the #newhere |
| 10 | // explainer — a cancelled prompt and a missing credential are deliberately |
| 11 | // indistinguishable, NotAllowedError either way, so the failure moment serves |
| 12 | // both readings). All crypto is client-side (crypto.js); the WebAuthn |
| 13 | // ceremonies + blob I/O live in backup-core.js. Status renders inline — |
| 14 | // never a toast. |
| 15 | // |
| 16 | // The identity-vs-key split, made visible: an Apple or email sign-in proves |
| 17 | // who you are (siwa.go / email.go answer with a device link token, exactly |
| 18 | // like a passkey ceremony) but can never decrypt — the vault blob only opens |
| 19 | // under a passkey's PRF-derived KEK. So an anchor sign-in lands signed in |
| 20 | // either empty (fresh account, no blob yet) or sealed, with a quiet |
| 21 | // unlock-with-your-passkey prompt. "Sign in" is the friendly name for |
| 22 | // unlocking the encrypted key vault; the server only ever holds opaque |
| 23 | // ciphertext, never a usable key. |
| 24 | |
| 25 | import { |
| 26 | kekFromPRF, generateDEK, exportDEK, importDEK, wrapDEK, unwrapDEK, |
| 27 | sealBlob, unpackBlob, openBlob, |
| 28 | } from "/static/crypto.js"; |
| 29 | import { |
| 30 | apiJSON, beginLogin, beginRegister, runLogin, runRegister, fetchBlob, putBlob, |
| 31 | } from "/static/backup-core.js"; |
| 32 | import { friendlyCeremonyError, formatCeremonyError } from "/static/ceremony-errors.js"; |
| 33 | import { |
| 34 | partitionServers, partitionArchived, setArchived, spaceRestoreURL, |
| 35 | toggleStar, moveInGroup, orderServers, filterServers, soleAdminHold, |
| 36 | addTombstone, clearTombstone, mergeRemoved, |
| 37 | } from "/static/serverlist.js"; |
| 38 | import { mountFader } from "/static/fader.js"; |
| 39 | import { parseJoinReturn, validJoinReturn, stashJoinReturn, readJoinReturnStash } from "/static/joinreturn.js"; |
| 40 | import { startHomeLoader, finishHomeLoader } from "/static/bootscene.js"; |
| 41 | import { BANDS, bandName, bandsOffered, tierForBand, regionCurrency, approxLocal, capNote } from "/static/fx.js"; |
| 42 | |
| 43 | // ---- session state ---------------------------------------------------------- |
| 44 | // The identity-vs-key split, applied to persistence (stay signed in, Option |
| 45 | // A): the link TOKEN outlives the tab — it also lives in localStorage with a |
| 46 | // 30-day rolling expiry, so a cold open lands signed in — but the DEK never |
| 47 | // does: it stays sessionStorage-only, so closing the tab always re-seals the |
| 48 | // list and a stolen disk yields membership, never plaintext. Cold opens land |
| 49 | // on the sealed-vault prompt; the passkey ceremony stays the only way in. |
| 50 | const state = { |
| 51 | get token() { return sessionStorage.getItem("home_token") || ""; }, |
| 52 | set token(v) { sessionStorage.setItem("home_token", v); }, |
| 53 | get dek() { return sessionStorage.getItem("home_dek") || ""; }, |
| 54 | set dek(v) { sessionStorage.setItem("home_dek", v); }, |
| 55 | get credId() { return sessionStorage.getItem("home_cred") || ""; }, |
| 56 | set credId(v) { sessionStorage.setItem("home_cred", v); }, |
| 57 | }; |
| 58 | let vault = null; // { obj, keys, version } once unlocked |
| 59 | let dekKey = null; // the imported CryptoKey |
| 60 | |
| 61 | // The persisted session, two localStorage keys: home_session (JSON {token, |
| 62 | // method}) and home_session_expires (plain epoch-ms, a separate key so the |
| 63 | // inline pre-paint gate can check it without JSON parsing). The expiry ROLLS: |
| 64 | // any boot that accepts the session re-persists, buying another 30 days. |
| 65 | const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; |
| 66 | |
| 67 | function persistSession(token, method) { |
| 68 | try { |
| 69 | localStorage.setItem("home_session", JSON.stringify({ token, method })); |
| 70 | localStorage.setItem("home_session_expires", String(Date.now() + SESSION_TTL_MS)); |
| 71 | } catch { /* private mode — the sign-in stays tab-only, as before */ } |
| 72 | } |
| 73 | |
| 74 | function clearPersistedSession() { |
| 75 | try { |
| 76 | localStorage.removeItem("home_session"); |
| 77 | localStorage.removeItem("home_session_expires"); |
| 78 | } catch { /* nothing to clear */ } |
| 79 | } |
| 80 | |
| 81 | // persistedSession reads the stored {token, method} while it's still live — |
| 82 | // null when absent, expired, or unreadable. Pure read; the boot path bumps |
| 83 | // the expiry only once the token has actually been accepted. |
| 84 | function persistedSession() { |
| 85 | try { |
| 86 | const exp = Number(localStorage.getItem("home_session_expires") || ""); |
| 87 | const s = JSON.parse(localStorage.getItem("home_session") || "null"); |
| 88 | return s && s.token && exp > Date.now() ? s : null; |
| 89 | } catch { return null; } |
| 90 | } |
| 91 | |
| 92 | // ---- tiny DOM helpers --------------------------------------------------------- |
| 93 | |
| 94 | const $ = (id) => document.getElementById(id); |
| 95 | |
| 96 | function setStatus(msg, kind) { |
| 97 | const el = $("status"); |
| 98 | el.textContent = msg || ""; |
| 99 | el.className = "status" + (kind ? " " + kind : ""); |
| 100 | } |
| 101 | function vaultStatus(msg, kind) { |
| 102 | const el = $("vault-status"); |
| 103 | el.textContent = msg || ""; |
| 104 | el.className = "status" + (kind ? " " + kind : ""); |
| 105 | } |
| 106 | function spaceStatus(msg, kind) { |
| 107 | const el = $("new-space-status"); |
| 108 | el.textContent = msg || ""; |
| 109 | el.className = "status" + (kind ? " " + kind : ""); |
| 110 | } |
| 111 | function archiveStatus(msg, kind) { |
| 112 | const el = $("archive-status"); |
| 113 | el.textContent = msg || ""; |
| 114 | el.className = "status" + (kind ? " " + kind : ""); |
| 115 | } |
| 116 | |
| 117 | // hostOf is the display host for a saved url, tolerant of a non-URL value. |
| 118 | function hostOf(url) { try { return new URL(url).host; } catch { return url || ""; } } |
| 119 | function labelOf(s) { return s.name || hostOf(s.url); } |
| 120 | |
| 121 | // fmtBytes renders a storage reading for members: decimal units (matching how |
| 122 | // storage is priced), one decimal for GB, whole numbers below. |
| 123 | function fmtBytes(n) { |
| 124 | if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + " GB"; |
| 125 | if (n >= 1e6) return Math.round(n / 1e6) + " MB"; |
| 126 | return "<1 MB"; |
| 127 | } |
| 128 | |
| 129 | // Whether this home server offers self-serve space creation (set from |
| 130 | // /api/features). The create-a-space controls only appear when it's both |
| 131 | // enabled here and you're signed in. |
| 132 | let spacesEnabled = false; |
| 133 | // provisioned: host → row from GET /api/spaces — which listed spaces THIS |
| 134 | // account created, plus their entitlement. billingEnabled mirrors the |
| 135 | // provisioner's flag; both drive the quiet Upgrade affordance + Supporter |
| 136 | // badge. null = not fetched yet (fetched lazily, once per sign-in). |
| 137 | let provisioned = null; |
| 138 | let billingEnabled = false; |
| 139 | let billingTiers = []; // the provisioner's price book: [{key, name, label}] |
| 140 | // A checkout round trip lands back here with ?upgraded=<host>; consumed (once) |
| 141 | // by the first showVault after return. |
| 142 | let upgradedHost = new URLSearchParams(location.search).get("upgraded") || ""; |
| 143 | if (upgradedHost) history.replaceState(null, "", location.pathname + location.hash); |
| 144 | // A pay-first create lands back with ?paidcreate=<session>&pcregion=<region> |
| 145 | // (Stripe fills the session id into the template). Consumed by the first |
| 146 | // vault render after sign-in, which claims the space into existence; a |
| 147 | // localStorage marker backs it up so a closed tab can still finish later. |
| 148 | let paidCreateSession = new URLSearchParams(location.search).get("paidcreate") || ""; |
| 149 | let paidCreateRegion = new URLSearchParams(location.search).get("pcregion") || ""; |
| 150 | if (paidCreateSession) history.replaceState(null, "", location.pathname + location.hash); |
| 151 | // A space's own menu deep-links here as #upgrade=<host>; consumed once the |
| 152 | // provisioner ledger is in hand (before sign-in it just waits in the fragment). |
| 153 | // Optional &band=<band> and &tier=<key> ride along (the space menu doesn't |
| 154 | // send them, but shared links and the setup conversation's plan chip do). |
| 155 | let upgradeLinkHost = ((location.hash.match(/^#upgrade=([A-Za-z0-9.-]+)((?:&(?:band=[a-z]+|tier=[a-z0-9_-]+))*)$/) || [])[1] || "").toLowerCase(); |
| 156 | // The conversation's chosen tier (#1374): highlights that card in the picker — |
| 157 | // a nudge, never a commitment; every card stays one tap from checkout. |
| 158 | let tierLink = ((location.hash.match(/[#&]tier=([a-z0-9_-]+)/) || [])[1] || "").toLowerCase(); |
| 159 | // A shared link pre-selects an honor band (docs/pricing-bands.md § The |
| 160 | // picker — "click a link, get the lower price, instantly"): #band=reduced, |
| 161 | // ?band=reduced, or riding an #upgrade fragment. The server honours whatever |
| 162 | // band arrives; this only pre-selects the picker. Unknown names are ignored. |
| 163 | let bandLink = (() => { |
| 164 | const m = location.hash.match(/[#&]band=([a-z]+)/) || location.search.match(/[?&]band=([a-z]+)/); |
| 165 | const b = m ? m[1] : ""; |
| 166 | return BANDS.some((x) => x.key === b) ? b : ""; |
| 167 | })(); |
| 168 | // A demo space's countdown banner deep-links here as #claim=<host>; waits in |
| 169 | // the fragment through sign-in — a demo needs no account, so its claimant |
| 170 | // often has to create their sign-in first, and the claim resumes after. |
| 171 | let claimLinkHost = ((location.hash.match(/^#claim=([A-Za-z0-9.-]+)$/) || [])[1] || "").toLowerCase(); |
| 172 | // A join page sends its visitor here to sign in FIRST (#joinret=<url> — the |
| 173 | // signup-first join flow). The return URL parks in sessionStorage so it |
| 174 | // survives the anchor doors' round trips (Apple/Google leave this page and |
| 175 | // come back via their trampolines, dropping the hash), and the first vault |
| 176 | // render after sign-in sends the person straight back to finish joining |
| 177 | // instead of showing the space list. Guarded both ends: joinreturn.js pins |
| 178 | // the destination to an https /join/ path. |
| 179 | { |
| 180 | const jr = parseJoinReturn(location.hash); |
| 181 | if (jr && validJoinReturn(jr)) { |
| 182 | try { sessionStorage.setItem("home_join_return", stashJoinReturn(jr, Date.now())); } catch { /* no storage — plain sign-in */ } |
| 183 | history.replaceState(null, "", location.pathname + location.search); |
| 184 | } |
| 185 | } |
| 186 | function joinReturnURL() { |
| 187 | try { return readJoinReturnStash(sessionStorage.getItem("home_join_return") || "", Date.now()); } catch { return ""; } |
| 188 | } |
| 189 | // consumeJoinReturn: the redirect back. True = navigating; callers stop rendering. |
| 190 | function consumeJoinReturn() { |
| 191 | const url = joinReturnURL(); |
| 192 | if (!url) return false; |
| 193 | try { sessionStorage.removeItem("home_join_return"); } catch {} |
| 194 | location.replace(url); |
| 195 | return true; |
| 196 | } |
| 197 | |
| 198 | // Ceremony options are pre-fetched so the button click reaches |
| 199 | // navigator.credentials.* synchronously — Safari drops the user activation |
| 200 | // across an intervening network await and rejects with NotAllowedError. |
| 201 | let loginBegin = null; |
| 202 | function prefetchLogin() { |
| 203 | beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; }); |
| 204 | } |
| 205 | let registerBegin = null; |
| 206 | function prefetchRegister() { |
| 207 | if (registerBegin) return; |
| 208 | beginRegister().then((v) => { registerBegin = v; }).catch(() => { registerBegin = null; }); |
| 209 | } |
| 210 | |
| 211 | // ---- blob save ---------------------------------------------------------------- |
| 212 | |
| 213 | // saveVault seals the current vault and PUTs it, retrying once on a version |
| 214 | // race (re-fetch, re-apply our object over the merge base, re-seal). The list |
| 215 | // is tiny, so last-writer-wins per entry is fine at this stage. |
| 216 | async function saveVault() { |
| 217 | for (let attempt = 0; attempt < 2; attempt++) { |
| 218 | const sealed = await sealBlob(vault.obj, dekKey, vault.keys); |
| 219 | const v = await putBlob(state.token, sealed, vault.version); |
| 220 | if (v !== null) { |
| 221 | vault.version = v; |
| 222 | return; |
| 223 | } |
| 224 | const cur = await fetchBlob(state.token); |
| 225 | if (!cur) throw new Error("backup disappeared"); |
| 226 | const opened = await openBlob(cur.bytes, dekKey); |
| 227 | const merged = new Map(opened.obj.servers.map((s) => [s.url, s])); |
| 228 | for (const s of vault.obj.servers) merged.set(s.url, s); |
| 229 | // Tombstones union newest-per-url, except where OUR list still holds the |
| 230 | // entry — this page's list is the user's current intent, so a removal that |
| 231 | // raced in from elsewhere loses to it (and an entry we just deleted here |
| 232 | // keeps its tombstone and wins over the other writer's copy). |
| 233 | const removed = mergeRemoved(opened.obj.removed, vault.obj.removed) |
| 234 | .filter((t) => !vault.obj.servers.some((s) => s.url === t.url)); |
| 235 | // Method labels merge the same way: the other writer's base, ours |
| 236 | // overlaid (last-writer-wins per key; a raced deletion may resurrect a |
| 237 | // label — they're a nicety, that's fine at this stage). |
| 238 | const methods = { ...(opened.obj.methods || {}), ...(vault.obj.methods || {}) }; |
| 239 | const obj = { |
| 240 | ...opened.obj, |
| 241 | servers: [...merged.values()].filter((s) => !removed.some((t) => t.url === s.url)), |
| 242 | }; |
| 243 | if (removed.length) obj.removed = removed; else delete obj.removed; |
| 244 | if (Object.keys(methods).length) obj.methods = methods; else delete obj.methods; |
| 245 | vault = { obj, keys: opened.keys, version: cur.version }; |
| 246 | } |
| 247 | throw new Error("could not save after retry"); |
| 248 | } |
| 249 | |
| 250 | // ---- sign in ------------------------------------------------------------------ |
| 251 | |
| 252 | // All the doors disable together while any of them is mid-flight — a second |
| 253 | // WebAuthn request would only cancel the sheet that's already up, and a second |
| 254 | // door mid-code-round-trip only confuses. The passkey controls additionally |
| 255 | // stay off in a browser with no WebAuthn (the Apple/email doors still work). |
| 256 | function setSignedOutBusy(busy) { |
| 257 | for (const id of ["apple-signin", "google-signin", "microsoft-signin", "github-signin", |
| 258 | "email-signin", "email-send", "email-verify", "other-ways", "otherways-apple", |
| 259 | "otherways-google", "otherways-microsoft", "otherways-github", "otherways-email"]) $(id).disabled = busy; |
| 260 | for (const id of ["signin", "create-passkey", "unlock-passkey", "otherways-passkey"]) { |
| 261 | $(id).disabled = busy || !window.PublicKeyCredential; |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | // markSignedIn records which method last signed in on this browser — this is |
| 266 | // deliberately the #1112 last-used marker ("passkey" | "email" | "apple"). |
| 267 | // localStorage on purpose: it outlives the session-only tab state, and |
| 268 | // sign-out leaves it — a returning person is still returning. Its absence is |
| 269 | // also what lets the newcomers-only demo card show, so marking retires the |
| 270 | // card on the spot. |
| 271 | function markSignedIn(method) { |
| 272 | try { localStorage.setItem("home_last_signin", method); } catch { /* no storage — the card just stays */ } |
| 273 | $("demo-offer").classList.add("hidden"); |
| 274 | } |
| 275 | |
| 276 | // everSignedIn gates the demo card: an absent marker (or no storage at all, |
| 277 | // where nothing could ever have been recorded) reads as a newcomer. |
| 278 | function everSignedIn() { |
| 279 | try { return !!localStorage.getItem("home_last_signin"); } catch { return false; } |
| 280 | } |
| 281 | |
| 282 | // ---- the card algorithm (#1112) ------------------------------------------------ |
| 283 | |
| 284 | // Which doors are LIVE on this build. Google and Microsoft ship lit; |
| 285 | // GitHub lights per-instance below (its flow needs a server-held client |
| 286 | // secret, so the page's client id is the signal). A future door slots in |
| 287 | // the same way: a flag here + its button in the markup — no restructuring. |
| 288 | const DOORS = { |
| 289 | apple: true, |
| 290 | email: true, |
| 291 | passkey: true, |
| 292 | google: true, // Android/ChromeOS's platform door (client id below) |
| 293 | microsoft: true, // Windows's platform door (client id below) |
| 294 | github: false, // set below — lit by the page's per-instance client id |
| 295 | }; |
| 296 | const GITHUB_CLIENT_ID = |
| 297 | (document.querySelector('meta[name="github-client-id"]') || {}).content || ""; |
| 298 | DOORS.github = !!GITHUB_CLIENT_ID && GITHUB_CLIENT_ID !== "__GITHUB_CLIENT_ID__"; |
| 299 | |
| 300 | // platformDefault picks the door for a browser with no last-used marker: the |
| 301 | // OS vendor's account, since that's the one a person most likely holds. |
| 302 | // userAgentData.platform where it exists (Chromium); navigator.platform + UA |
| 303 | // as the fallback (Safari/Firefox — iPadOS reports "MacIntel", which still |
| 304 | // reads apple). A platform whose door is still dark falls through to |
| 305 | // email-first until it ships. |
| 306 | function platformDefault() { |
| 307 | const hints = ((navigator.userAgentData && navigator.userAgentData.platform) || |
| 308 | navigator.platform || "") + " " + navigator.userAgent; |
| 309 | const door = |
| 310 | /Mac|iPhone|iPad|iPod|iOS/i.test(hints) ? "apple" : |
| 311 | /Android|CrOS|Chrome OS/i.test(hints) ? "google" : |
| 312 | /Windows|Win32|Win64/i.test(hints) ? "microsoft" : // never bare /Win/ — "Darwin" contains it |
| 313 | "email"; |
| 314 | return DOORS[door] ? door : "email"; |
| 315 | } |
| 316 | |
| 317 | // primaryDoor: last-used wins, else the platform default. The marker only |
| 318 | // counts when it names a LIVE door — a value from a newer build (say |
| 319 | // "google" before this build lights it) must not compose a door that isn't |
| 320 | // here. |
| 321 | function primaryDoor() { |
| 322 | let last = ""; |
| 323 | try { last = localStorage.getItem("home_last_signin") || ""; } catch { /* no storage — platform default */ } |
| 324 | return DOORS[last] ? last : platformDefault(); |
| 325 | } |
| 326 | |
| 327 | // composeCard arranges the main card: exactly ONE door — last-used when |
| 328 | // known, else the platform default — then the quiet "Other ways to sign in" |
| 329 | // link. Never a second door on the card (Paul 2026-07-19, revising #1112's |
| 330 | // email-always-beneath rule): every demoted door, email included, lives in |
| 331 | // the all-ways step. Runs at boot and again on sign-out (the marker |
| 332 | // survives it and may have moved). |
| 333 | function composeCard() { |
| 334 | const primary = primaryDoor(); |
| 335 | const apple = $("apple-signin"), email = $("email-signin"), passkey = $("signin"); |
| 336 | const google = $("google-signin"), microsoft = $("microsoft-signin"); |
| 337 | const github = $("github-signin"); |
| 338 | github.classList.toggle("hidden", primary !== "github"); |
| 339 | apple.classList.toggle("hidden", primary !== "apple"); |
| 340 | google.classList.toggle("hidden", primary !== "google"); |
| 341 | microsoft.classList.toggle("hidden", primary !== "microsoft"); |
| 342 | // Promotions restyle in place: email sheds .secondary when it leads, and |
| 343 | // the passkey door trades its grey link for a proper primary button. |
| 344 | // Wholesale className is safe — these two never carry other classes. |
| 345 | email.className = primary === "email" ? "" : "secondary hidden"; |
| 346 | passkey.className = primary === "passkey" ? "" : "passkey-link hidden"; |
| 347 | // DOM order is focus order: the one door, then the link. append MOVES the |
| 348 | // existing (already-wired) nodes; hidden doors stay parked, unfocusable. |
| 349 | $("main-doors").append({ apple, email, passkey, google, microsoft, github }[primary], $("other-ways")); |
| 350 | } |
| 351 | |
| 352 | async function signIn() { |
| 353 | setSignedOutBusy(true); |
| 354 | setStatus("Confirming it’s you…", "busy"); |
| 355 | const { token, credId, prf, proof } = await runLogin(loginBegin); |
| 356 | state.token = token; |
| 357 | state.credId = credId; |
| 358 | noteProof(proof); // fills the confirm-step pocket (Sign-in options) |
| 359 | markSignedIn("passkey"); |
| 360 | persistSession(token, "passkey"); |
| 361 | const got = await fetchBlob(token); |
| 362 | if (!got) { |
| 363 | // Zero spaces is a SUCCESS state, not a dead end: land in the normal |
| 364 | // signed-in view, empty, with the create affordance. A PRF result lets |
| 365 | // this passkey seal future saves (same shape as createPasskey — and like |
| 366 | // there, nothing is written until the first space enrols). |
| 367 | if (prf) { |
| 368 | const kek = await kekFromPRF(prf); |
| 369 | dekKey = await generateDEK(); |
| 370 | state.dek = await exportDEK(dekKey); |
| 371 | vault = { obj: { v: 1, servers: [] }, keys: { [credId]: await wrapDEK(dekKey, kek) }, version: "0" }; |
| 372 | } else { |
| 373 | dekKey = null; |
| 374 | vault = { obj: { v: 1, servers: [] }, keys: {}, version: "0" }; |
| 375 | } |
| 376 | setStatus(""); |
| 377 | showVault(); |
| 378 | return; |
| 379 | } |
| 380 | if (!prf) { |
| 381 | setStatus("Signed in, but this browser didn't return a PRF result, so your spaces can't be unlocked here.", "error"); |
| 382 | setSignedOutBusy(false); |
| 383 | return; |
| 384 | } |
| 385 | const kek = await kekFromPRF(prf); |
| 386 | const { keys } = unpackBlob(got.bytes); |
| 387 | const wrapped = keys[credId]; |
| 388 | if (!wrapped) { |
| 389 | setStatus("That sign-in can't unlock these spaces.", "error"); |
| 390 | setSignedOutBusy(false); |
| 391 | return; |
| 392 | } |
| 393 | dekKey = await unwrapDEK(wrapped, kek); |
| 394 | state.dek = await exportDEK(dekKey); |
| 395 | const opened = await openBlob(got.bytes, dekKey); |
| 396 | vault = { obj: opened.obj, keys: opened.keys, version: got.version }; |
| 397 | setStatus(""); // "Signed in" is obvious — the list appearing says it |
| 398 | showVault(); |
| 399 | } |
| 400 | |
| 401 | // ---- create a sign-in (the up-front "Create your sign-in" action) -------------- |
| 402 | |
| 403 | // createPasskey registers a brand-new passkey and starts an empty vault under |
| 404 | // it, then lands in the (empty) spaces list like any sign-in. Reached from the |
| 405 | // up-front create button — quieter than Sign in, but never hidden behind a |
| 406 | // failed ceremony (see the header note). |
| 407 | async function createPasskey() { |
| 408 | setSignedOutBusy(true); |
| 409 | setStatus("Creating your sign-in…", "busy"); |
| 410 | let reg; |
| 411 | try { |
| 412 | reg = await runRegister(registerBegin); |
| 413 | } catch (e) { |
| 414 | registerBegin = null; |
| 415 | prefetchRegister(); // options were spent — refresh for a retry |
| 416 | if (e.message === "no-prf") { |
| 417 | setStatus("This device can't protect your sign-in here (no PRF support). You can still use add-device links to move between devices.", "error"); |
| 418 | setSignedOutBusy(false); |
| 419 | return; |
| 420 | } |
| 421 | throw e; |
| 422 | } |
| 423 | state.token = reg.token; |
| 424 | state.credId = reg.credId; |
| 425 | noteProof(reg.proof); // register/finish mints one too (finishCeremony) |
| 426 | markSignedIn("passkey"); // register/finish is a sign-in too (#1112) |
| 427 | persistSession(reg.token, "passkey"); |
| 428 | |
| 429 | const kek = await kekFromPRF(reg.prf); |
| 430 | dekKey = await generateDEK(); |
| 431 | state.dek = await exportDEK(dekKey); |
| 432 | // Deliberately NOT persisted: no blob is written until the first space is |
| 433 | // enrolled (enroll.js starts one under this passkey). Blob-existence is the |
| 434 | // server's creation-eligibility gate — "has at least one space" — and an |
| 435 | // empty vault saved here would quietly turn every fresh passkey into an |
| 436 | // eligible account (the + stays hidden, but the API wouldn't 403). |
| 437 | vault = { |
| 438 | obj: { v: 1, servers: [] }, |
| 439 | keys: { [reg.credId]: await wrapDEK(dekKey, kek) }, |
| 440 | version: "0", |
| 441 | }; |
| 442 | setSignedOutBusy(false); |
| 443 | setStatus("Your sign-in is ready — next time it's one tap.", "ok"); |
| 444 | showVault(); |
| 445 | } |
| 446 | |
| 447 | // ---- the identity anchors: Apple + email --------------------------------------- |
| 448 | |
| 449 | // startAppleSignIn hands the whole page to Apple's authorize endpoint — a |
| 450 | // full-page navigate, no Apple JS SDK (the CSP pins scripts to self). The |
| 451 | // state is minted here, "web."-prefixed so the /siwa/return trampoline |
| 452 | // bounces back to this page instead of the native apps' custom scheme, and |
| 453 | // parked in sessionStorage for the return check. |
| 454 | // The Apple round trip's waiting face: the ambient smoke (already running |
| 455 | // behind the page) comes to full and a ring holds the lockup's spot — no |
| 456 | // brand yet; this is the wait, not the arrival. A pure opacity crossfade |
| 457 | // over the boot cover's classes: nothing heavy starts at tap time. The same |
| 458 | // face goes up pre-paint on the return leg (the #siwa= gate in index.html). |
| 459 | function engageSiwaCover() { |
| 460 | document.documentElement.classList.add("booting", "siwa-wait"); |
| 461 | } |
| 462 | function releaseSiwaCover() { |
| 463 | document.documentElement.classList.remove("booting", "siwa-wait"); |
| 464 | } |
| 465 | |
| 466 | // The Apple tap's staged wait: stage-clear → (timer) the waiting face. A |
| 467 | // cancelled sheet never navigates, so any later input — or a bfcache |
| 468 | // return — releases the whole arrangement, cover included. |
| 469 | let applePendingTimer = 0; |
| 470 | let applePendingBtn = null; // whichever Apple door was pressed (main card or all-ways) |
| 471 | function releaseApplePending() { |
| 472 | clearTimeout(applePendingTimer); |
| 473 | releaseSiwaCover(); |
| 474 | document.querySelector("main.card").classList.remove("apple-pending"); |
| 475 | if (applePendingBtn) { |
| 476 | applePendingBtn.disabled = false; |
| 477 | applePendingBtn.classList.remove("loading"); |
| 478 | applePendingBtn = null; |
| 479 | } |
| 480 | window.removeEventListener("pointerdown", releaseApplePending, true); |
| 481 | window.removeEventListener("keydown", releaseApplePending, true); |
| 482 | // A cancelled trip must not leave the methods step's round-trip markers |
| 483 | // behind — a stale one would misroute a LATER Apple sign-in into link mode. |
| 484 | try { |
| 485 | sessionStorage.removeItem("home_siwa_link"); |
| 486 | sessionStorage.removeItem("home_methods_return"); |
| 487 | sessionStorage.removeItem("home_methods_intent"); |
| 488 | } catch { /* nothing stored */ } |
| 489 | } |
| 490 | |
| 491 | function startAppleSignIn(e, opts = {}) { |
| 492 | // Tap: the button spins and the REST of the form fades away — the wait |
| 493 | // for Apple's sheet is just the button at work. By 1.6s the sheet is up, |
| 494 | // so the smoke cover takes over then; post-fingerprint there is nothing |
| 495 | // left to flash. pagehide backstops fast confirms; pointer/key input or a |
| 496 | // bfcache return releases a cancelled arrangement. The main card's door |
| 497 | // and the all-ways step's are the same handler — the stage-clear CSS keys |
| 498 | // off .loading, not an id, so it clears whichever surface is up. |
| 499 | const appleBtn = e.currentTarget; |
| 500 | applePendingBtn = appleBtn; |
| 501 | appleBtn.disabled = true; |
| 502 | appleBtn.classList.add("loading"); |
| 503 | document.querySelector("main.card").classList.add("apple-pending"); |
| 504 | applePendingTimer = setTimeout(engageSiwaCover, 1600); |
| 505 | window.addEventListener("pointerdown", releaseApplePending, true); |
| 506 | window.addEventListener("keydown", releaseApplePending, true); |
| 507 | const rand = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join(""); |
| 508 | const st = "web." + rand; |
| 509 | // The methods step's round-trip markers ride sessionStorage beside the |
| 510 | // state: linkProof turns the #siwa= return into "add Apple to MY account" |
| 511 | // (link:true + the stashed proof), methodsReturn routes a confirm-with- |
| 512 | // Apple sign-in straight back to the step, and intent is the serialized |
| 513 | // in-progress change (intentToken) so the return RESUMES it instead of |
| 514 | // landing on the bare list. Set fresh or CLEARED per trip, so a stale |
| 515 | // marker from an abandoned attempt can never misroute later. |
| 516 | try { |
| 517 | if (opts.linkProof) sessionStorage.setItem("home_siwa_link", JSON.stringify(opts.linkProof)); |
| 518 | else sessionStorage.removeItem("home_siwa_link"); |
| 519 | if (opts.methodsReturn) sessionStorage.setItem("home_methods_return", "1"); |
| 520 | else sessionStorage.removeItem("home_methods_return"); |
| 521 | if (opts.intent) sessionStorage.setItem("home_methods_intent", opts.intent); |
| 522 | else sessionStorage.removeItem("home_methods_intent"); |
| 523 | } catch { /* no storage — the return leg lands as a plain sign-in */ } |
| 524 | sessionStorage.setItem("home_siwa_state", st); |
| 525 | location.href = "https://appleid.apple.com/auth/authorize" + |
| 526 | "?client_id=com.elevenmessenger.signin" + |
| 527 | "&redirect_uri=" + encodeURIComponent(location.origin + "/siwa/return") + |
| 528 | "&response_type=code%20id_token" + |
| 529 | "&response_mode=form_post" + |
| 530 | "&state=" + encodeURIComponent(st); |
| 531 | } |
| 532 | |
| 533 | // finishSiwa completes the round trip: the id_token from the return fragment |
| 534 | // goes to the one verifier (/api/siwa/verify), which answers exactly like a |
| 535 | // passkey ceremony — then the anchor-session landing sorts the outcomes. The |
| 536 | // pre-paint waiting face (smoke + ring, html.siwa-wait) is up the whole time; |
| 537 | // verify success earns the arrival: ring gives way to the Eleven lockup, |
| 538 | // a beat to land, then the cover lifts as the spaces screen slides up. |
| 539 | async function finishSiwa(idToken, backToMethods = false, intentStr = "") { |
| 540 | setStatus("Confirming it’s you…", "busy"); |
| 541 | try { |
| 542 | const res = await apiJSON("/api/siwa/verify", { |
| 543 | method: "POST", |
| 544 | headers: { "Content-Type": "application/json" }, |
| 545 | body: JSON.stringify({ identity_token: idToken }), |
| 546 | }); |
| 547 | noteProof(res.proof); // fills the confirm-step pocket (Sign-in options) |
| 548 | markSignedIn("apple"); |
| 549 | persistSession(res.token, "apple"); |
| 550 | await enterAnchorSession(res); // renders the landing beneath the cover |
| 551 | } catch { |
| 552 | if (backToMethods && state.token) { |
| 553 | // A failed confirm-with-Apple: the tab's session is untouched — land |
| 554 | // back on the list with the refusal (the intent was already consumed). |
| 555 | await openMethods(); |
| 556 | methodsStatus("That Apple sign-in couldn’t be confirmed — try again.", "error"); |
| 557 | finishHomeLoader({ immediate: true }); |
| 558 | return; |
| 559 | } |
| 560 | // No brand moment for a sign-in that didn't happen: lift straight to |
| 561 | // the signed-out card and say what went wrong. |
| 562 | setStatus("That Apple sign-in couldn’t be confirmed — try again.", "error"); |
| 563 | finishHomeLoader({ immediate: true }); |
| 564 | return; |
| 565 | } |
| 566 | if (backToMethods) { |
| 567 | // A confirm-with-Apple round trip (the methods step sent us): no arrival |
| 568 | // pageantry — straight back to Sign-in options, proof in the pocket. A |
| 569 | // persisted intent resumes the interrupted change — the add step opens |
| 570 | // (or the removal re-arms) instead of landing on the bare list. |
| 571 | await openMethods(); |
| 572 | const intent = parseIntentToken(intentStr); |
| 573 | if (intent && freshProof()) runIntent(intent, freshProof()); |
| 574 | finishHomeLoader({ immediate: true }); |
| 575 | return; |
| 576 | } |
| 577 | anchorArrival(); |
| 578 | } |
| 579 | |
| 580 | // anchorArrival is phase two for a covered anchor sign-in (Apple's return leg, |
| 581 | // the email magic link): the lockup fades in over the ring's spot (~400ms), |
| 582 | // dwells (~450ms), then the cover lifts while the spaces screen slides up |
| 583 | // into place beneath it. Reduced motion: no dwell, no slide — plain fades |
| 584 | // (brand-in and arrive are inert under the reduce query). |
| 585 | function anchorArrival() { |
| 586 | const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 587 | document.documentElement.classList.remove("siwa-wait"); // ring → lockup |
| 588 | $("app-loading").classList.add("brand-in"); |
| 589 | setTimeout(() => { |
| 590 | // .arrive stays on while the vault is visible — swapping the computed |
| 591 | // animation-name back to .screen's live would RESTART the entry fade |
| 592 | // (the after-flash); it comes off in hideVault, under display:none. |
| 593 | $("vault").classList.add("arrive"); |
| 594 | finishHomeLoader({ immediate: true }); |
| 595 | }, reduced ? 0 : 850); // 400ms brand fade + 450ms dwell |
| 596 | } |
| 597 | |
| 598 | // finishSiwaLink completes an ADD-APPLE round trip (the #siwa= return with a |
| 599 | // link marker): the ceremony proof stashed before leaving for Apple rode |
| 600 | // sessionStorage — same tab — and must still be inside its 5-minute window on |
| 601 | // return. The link verify never signs anyone in; this tab's own session |
| 602 | // (which survived the navigation) is resumed instead, and the outcome lands |
| 603 | // inline on the Sign-in options step. |
| 604 | async function finishSiwaLink(idToken, markRaw) { |
| 605 | let mark = null; |
| 606 | try { mark = JSON.parse(markRaw); } catch { /* mangled marker — treat as lapsed */ } |
| 607 | let outcome, conflict = false; |
| 608 | if (!mark || !mark.proof || !(mark.expires > Date.now())) { |
| 609 | // The proof's 5-minute window lapsed during the trip — re-prompt. |
| 610 | dropProof(); // the pocket mirrors the same lapsed proof |
| 611 | outcome = { msg: "That took a little too long — confirm it's you again, then tap Add Apple.", kind: "error" }; |
| 612 | } else { |
| 613 | try { |
| 614 | const res = await fetch("/api/siwa/verify", { |
| 615 | method: "POST", |
| 616 | headers: { "Content-Type": "application/json", "X-Ceremony-Proof": mark.proof }, |
| 617 | body: JSON.stringify({ identity_token: idToken, link: true }), |
| 618 | }); |
| 619 | if (res.ok) { |
| 620 | // The proof just verified — good for the window's remainder, so a |
| 621 | // chained second add skips the confirm step. |
| 622 | noteProof(mark.proof, mark.expires); |
| 623 | outcome = { msg: "Apple is now a way into your account.", kind: "ok" }; |
| 624 | } else { |
| 625 | // The server's refusals verbatim: a lapsed proof (401 — the line |
| 626 | // asks to confirm again), or the 409 "that Apple ID already signs in |
| 627 | // somewhere else" — the COMBINE doorway (#1098): the offer keys off |
| 628 | // the status, never off message text. |
| 629 | const msg = ((await res.text().catch(() => "")) || "").trim(); |
| 630 | conflict = res.status === 409; |
| 631 | // A 409 still verified the proof (only the identity clashed); any |
| 632 | // other refusal is almost always the lapsed proof — re-confirm next. |
| 633 | if (conflict) noteProof(mark.proof, mark.expires); else dropProof(); |
| 634 | outcome = { msg: msg || "Couldn't add Apple — try again.", kind: "error" }; |
| 635 | } |
| 636 | } catch { |
| 637 | outcome = { msg: "Couldn't add Apple — try again.", kind: "error" }; |
| 638 | } |
| 639 | } |
| 640 | if (await resumeAfterLinkReturn()) { |
| 641 | await openMethods(); |
| 642 | methodsStatus(outcome.msg, outcome.kind); |
| 643 | if (conflict) { |
| 644 | combine = { kind: "apple" }; |
| 645 | showCombineOffer("methods-status"); |
| 646 | } |
| 647 | } else { |
| 648 | // No session survived the round trip (storage cleared mid-flight) — the |
| 649 | // doors, with the outcome where the visitor will read it. |
| 650 | setStatus(outcome.msg, outcome.kind === "ok" ? "" : "error"); |
| 651 | } |
| 652 | finishHomeLoader({ immediate: true }); |
| 653 | } |
| 654 | |
| 655 | // ---- the OIDC web doors (Google today; Microsoft slots in) ---------------------- |
| 656 | |
| 657 | // The Apple journey, generalized per oidc.go's provider table: the page |
| 658 | // mints the state AND a nonce (Google refuses a bare id_token request; the |
| 659 | // verifier checks it round-trips), departs for the provider's authorize |
| 660 | // endpoint, and lands back through the provider-blind /oidc/return |
| 661 | // trampoline as #oidc=<id_token>&state=web.<r>. Which door was opened rides |
| 662 | // sessionStorage (home_oidc_state), never the fragment. The pending/cover |
| 663 | // choreography and the methods round-trip markers are the Apple machinery, |
| 664 | // shared verbatim. Like Apple's client id, a provider's is a public |
| 665 | // identifier and lives here in code. |
| 666 | const GOOGLE_CLIENT_ID = |
| 667 | "981276018948-b4cr3ctqi6cr9e8rs2fmqouip1hmd9iq.apps.googleusercontent.com"; |
| 668 | const MICROSOFT_CLIENT_ID = "25874cf3-213c-4124-9641-65de8a513787"; |
| 669 | const OIDC_WEB = { |
| 670 | github: { |
| 671 | display: "GitHub", |
| 672 | verify: "/api/oidc/github/verify", |
| 673 | // Code flow, not OIDC: no nonce; the server exchanges the code with |
| 674 | // the secret it holds (github.go) and answers the same contract. |
| 675 | payload: (cred) => ({ code: cred }), |
| 676 | authorize: (st) => "https://github.com/login/oauth/authorize" + |
| 677 | "?client_id=" + encodeURIComponent(GITHUB_CLIENT_ID) + |
| 678 | "&redirect_uri=" + encodeURIComponent(location.origin + "/oidc/return") + |
| 679 | "&state=" + encodeURIComponent(st), |
| 680 | }, |
| 681 | microsoft: { |
| 682 | display: "Microsoft", |
| 683 | verify: "/api/oidc/microsoft/verify", |
| 684 | // The "common" endpoint admits any tenant plus personal accounts — |
| 685 | // oidc.go's checkIssuer handles the per-tenant iss. |
| 686 | authorize: (st, nonce, sel) => "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" + |
| 687 | "?client_id=" + encodeURIComponent(MICROSOFT_CLIENT_ID) + |
| 688 | "&redirect_uri=" + encodeURIComponent(location.origin + "/oidc/return") + |
| 689 | "&response_type=id_token" + |
| 690 | "&response_mode=form_post" + |
| 691 | "&scope=openid" + |
| 692 | "&nonce=" + encodeURIComponent(nonce) + |
| 693 | "&state=" + encodeURIComponent(st) + |
| 694 | (sel ? "&prompt=select_account" : ""), |
| 695 | }, |
| 696 | google: { |
| 697 | display: "Google", |
| 698 | verify: "/api/oidc/google/verify", |
| 699 | authorize: (st, nonce, sel) => "https://accounts.google.com/o/oauth2/v2/auth" + |
| 700 | "?client_id=" + encodeURIComponent(GOOGLE_CLIENT_ID) + |
| 701 | "&redirect_uri=" + encodeURIComponent(location.origin + "/oidc/return") + |
| 702 | "&response_type=id_token" + |
| 703 | "&response_mode=form_post" + |
| 704 | "&scope=openid" + |
| 705 | "&nonce=" + encodeURIComponent(nonce) + |
| 706 | "&state=" + encodeURIComponent(st) + |
| 707 | (sel ? "&prompt=select_account" : ""), |
| 708 | }, |
| 709 | }; |
| 710 | |
| 711 | function startOidcSignIn(provider, e, opts = {}) { |
| 712 | // The same staged wait as the Apple tap (see startAppleSignIn): button |
| 713 | // spins, stage clears, the smoke cover takes over by the time the |
| 714 | // provider's page is up; any input releases a cancelled arrangement. |
| 715 | const btn = e.currentTarget; |
| 716 | applePendingBtn = btn; |
| 717 | btn.disabled = true; |
| 718 | btn.classList.add("loading"); |
| 719 | document.querySelector("main.card").classList.add("apple-pending"); |
| 720 | applePendingTimer = setTimeout(engageSiwaCover, 1600); |
| 721 | window.addEventListener("pointerdown", releaseApplePending, true); |
| 722 | window.addEventListener("keydown", releaseApplePending, true); |
| 723 | const hex = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), |
| 724 | (b) => b.toString(16).padStart(2, "0")).join(""); |
| 725 | const st = "web." + hex(); |
| 726 | const nonce = hex(); |
| 727 | try { |
| 728 | if (opts.linkProof) sessionStorage.setItem("home_siwa_link", JSON.stringify(opts.linkProof)); |
| 729 | else sessionStorage.removeItem("home_siwa_link"); |
| 730 | if (opts.methodsReturn) sessionStorage.setItem("home_methods_return", "1"); |
| 731 | else sessionStorage.removeItem("home_methods_return"); |
| 732 | if (opts.intent) sessionStorage.setItem("home_methods_intent", opts.intent); |
| 733 | else sessionStorage.removeItem("home_methods_intent"); |
| 734 | } catch { /* no storage — the return leg lands as a plain sign-in */ } |
| 735 | sessionStorage.setItem("home_oidc_state", JSON.stringify({ state: st, provider, nonce })); |
| 736 | location.href = OIDC_WEB[provider].authorize(st, nonce, !!opts.select); |
| 737 | } |
| 738 | |
| 739 | // finishOidc — finishSiwa's twin against /api/oidc/{provider}/verify: the |
| 740 | // same one-verifier answer (token + proof), the same anchor-session landing |
| 741 | // under the waiting face. |
| 742 | function oidcPayload(p, cred, rec) { |
| 743 | return p.payload ? p.payload(cred) : { identity_token: cred, nonce: rec.nonce }; |
| 744 | } |
| 745 | |
| 746 | async function finishOidc(rec, cred, backToMethods = false, intentStr = "") { |
| 747 | const p = OIDC_WEB[rec.provider]; |
| 748 | setStatus("Confirming it’s you…", "busy"); |
| 749 | try { |
| 750 | const res = await apiJSON(p.verify, { |
| 751 | method: "POST", |
| 752 | headers: { "Content-Type": "application/json" }, |
| 753 | body: JSON.stringify(oidcPayload(p, cred, rec)), |
| 754 | }); |
| 755 | noteProof(res.proof); |
| 756 | markSignedIn(rec.provider); |
| 757 | persistSession(res.token, rec.provider); |
| 758 | await enterAnchorSession(res); |
| 759 | } catch { |
| 760 | if (backToMethods && state.token) { |
| 761 | await openMethods(); |
| 762 | methodsStatus("That " + p.display + " sign-in couldn’t be confirmed — try again.", "error"); |
| 763 | finishHomeLoader({ immediate: true }); |
| 764 | return; |
| 765 | } |
| 766 | setStatus("That " + p.display + " sign-in couldn’t be confirmed — try again.", "error"); |
| 767 | finishHomeLoader({ immediate: true }); |
| 768 | return; |
| 769 | } |
| 770 | if (backToMethods) { |
| 771 | await openMethods(); |
| 772 | const intent = parseIntentToken(intentStr); |
| 773 | if (intent && freshProof()) runIntent(intent, freshProof()); |
| 774 | finishHomeLoader({ immediate: true }); |
| 775 | return; |
| 776 | } |
| 777 | // The landing rendered beneath the cover — this is what LIFTS it (the |
| 778 | // ring gives way to the lockup, then the spaces screen). Its omission |
| 779 | // stranded the first live Google sign-in on the waiting face. |
| 780 | anchorArrival(); |
| 781 | } |
| 782 | |
| 783 | // finishOidcLink — finishSiwaLink's twin: the ADD round trip (link:true + |
| 784 | // the stashed proof). The 409 grows the same combine offer as Apple's leg — |
| 785 | // the prove step sends the person back through the clashing provider's own |
| 786 | // door (combine-oidc-go → the merge-<provider> intent). |
| 787 | async function finishOidcLink(rec, cred, markRaw) { |
| 788 | const p = OIDC_WEB[rec.provider]; |
| 789 | let mark = null; |
| 790 | try { mark = JSON.parse(markRaw); } catch { /* mangled marker — treat as lapsed */ } |
| 791 | let outcome, conflict = false; |
| 792 | if (!mark || !mark.proof || !(mark.expires > Date.now())) { |
| 793 | dropProof(); // the pocket mirrors the same lapsed proof |
| 794 | outcome = { msg: "That took a little too long — confirm it's you again, then tap Add " + p.display + ".", kind: "error" }; |
| 795 | } else { |
| 796 | try { |
| 797 | const res = await fetch(p.verify, { |
| 798 | method: "POST", |
| 799 | headers: { "Content-Type": "application/json", "X-Ceremony-Proof": mark.proof }, |
| 800 | body: JSON.stringify({ ...oidcPayload(p, cred, rec), link: true }), |
| 801 | }); |
| 802 | if (res.ok) { |
| 803 | // The proof just verified — good for the window's remainder, so a |
| 804 | // chained second add skips the confirm step. |
| 805 | noteProof(mark.proof, mark.expires); |
| 806 | outcome = { msg: p.display + " is now a way into your account.", kind: "ok" }; |
| 807 | } else { |
| 808 | const msg = ((await res.text().catch(() => "")) || "").trim(); |
| 809 | conflict = res.status === 409; |
| 810 | // A 409 still verified the proof (only the identity clashed); any |
| 811 | // other refusal is almost always the lapsed proof — re-confirm next. |
| 812 | if (conflict) noteProof(mark.proof, mark.expires); else dropProof(); |
| 813 | outcome = { msg: msg || "Couldn't add " + p.display + " — try again.", kind: "error" }; |
| 814 | } |
| 815 | } catch { |
| 816 | outcome = { msg: "Couldn't add " + p.display + " — try again.", kind: "error" }; |
| 817 | } |
| 818 | } |
| 819 | if (await resumeAfterLinkReturn()) { |
| 820 | await openMethods(); |
| 821 | methodsStatus(outcome.msg, outcome.kind); |
| 822 | if (conflict) { |
| 823 | combine = { kind: rec.provider }; |
| 824 | showCombineOffer("methods-status"); |
| 825 | } |
| 826 | } else { |
| 827 | setStatus(outcome.msg, outcome.kind === "ok" ? "" : "error"); |
| 828 | } |
| 829 | finishHomeLoader({ immediate: true }); |
| 830 | } |
| 831 | |
| 832 | // finishSiwaMergeProve is the merge-apple intent's return leg: the combine |
| 833 | // step sent the person to Apple to prove the OTHER account is theirs. The |
| 834 | // id_token verifies in SIGN-IN mode, but the answer is deliberately NOT |
| 835 | // adopted — the absorbed account's token+proof are held in memory for the |
| 836 | // merge only, and this tab's own (survivor) session resumes as-is. |
| 837 | async function finishSiwaMergeProve(idToken) { |
| 838 | let creds = null; |
| 839 | try { |
| 840 | const res = await apiJSON("/api/siwa/verify", { |
| 841 | method: "POST", |
| 842 | headers: { "Content-Type": "application/json" }, |
| 843 | body: JSON.stringify({ identity_token: idToken }), |
| 844 | }); |
| 845 | creds = { token: res.token, proof: res.proof, expires: Date.now() + PROOF_TTL_MS }; |
| 846 | } catch { /* falls through to the refusal below */ } |
| 847 | if (!(await resumeAfterLinkReturn())) { |
| 848 | setStatus("The combine couldn't resume — sign in and try again.", "error"); |
| 849 | finishHomeLoader({ immediate: true }); |
| 850 | return; |
| 851 | } |
| 852 | await openMethods(); |
| 853 | if (!creds) { |
| 854 | methodsStatus("That Apple sign-in couldn’t be confirmed — try combining again.", "error"); |
| 855 | finishHomeLoader({ immediate: true }); |
| 856 | return; |
| 857 | } |
| 858 | combine = { kind: "apple", absorbedToken: creds.token, absorbedProof: creds.proof, absorbedExpires: creds.expires }; |
| 859 | openCombineStep(); // proven — lands straight on the spaces stage |
| 860 | finishHomeLoader({ immediate: true }); |
| 861 | } |
| 862 | |
| 863 | // finishOidcMergeProve — finishSiwaMergeProve's provider twin: the combine |
| 864 | // step sent the person to Google/Microsoft/GitHub to prove the OTHER |
| 865 | // account; the answer is held for the merge, never adopted. |
| 866 | async function finishOidcMergeProve(rec, cred) { |
| 867 | const p = OIDC_WEB[rec.provider]; |
| 868 | let creds = null; |
| 869 | try { |
| 870 | const res = await apiJSON(p.verify, { |
| 871 | method: "POST", |
| 872 | headers: { "Content-Type": "application/json" }, |
| 873 | body: JSON.stringify(oidcPayload(p, cred, rec)), |
| 874 | }); |
| 875 | creds = { token: res.token, proof: res.proof, expires: Date.now() + PROOF_TTL_MS }; |
| 876 | } catch { /* falls through to the refusal below */ } |
| 877 | if (!(await resumeAfterLinkReturn())) { |
| 878 | setStatus("The combine couldn't resume — sign in and try again.", "error"); |
| 879 | finishHomeLoader({ immediate: true }); |
| 880 | return; |
| 881 | } |
| 882 | await openMethods(); |
| 883 | if (!creds) { |
| 884 | methodsStatus("That " + p.display + " sign-in couldn't be confirmed — try combining again.", "error"); |
| 885 | finishHomeLoader({ immediate: true }); |
| 886 | return; |
| 887 | } |
| 888 | combine = { kind: rec.provider, absorbedToken: creds.token, absorbedProof: creds.proof, absorbedExpires: creds.expires }; |
| 889 | openCombineStep(); // proven — lands straight on the spaces stage |
| 890 | finishHomeLoader({ immediate: true }); |
| 891 | } |
| 892 | |
| 893 | // resumeAfterLinkReturn rebuilds the signed-in view after an add-Apple round |
| 894 | // trip: the tab's own session survived the navigation (sessionStorage), or |
| 895 | // the stay-signed-in token did (localStorage). True once a signed-in screen |
| 896 | // is up again; false means every session evaporated mid-trip. |
| 897 | async function resumeAfterLinkReturn() { |
| 898 | try { |
| 899 | if (state.token && state.dek) { |
| 900 | const got = await fetchBlob(state.token); |
| 901 | if (got) { |
| 902 | dekKey = await importDEK(state.dek); |
| 903 | const opened = await openBlob(got.bytes, dekKey); |
| 904 | vault = { obj: opened.obj, keys: opened.keys, version: got.version }; |
| 905 | showVault(); |
| 906 | return true; |
| 907 | } |
| 908 | } |
| 909 | const saved = state.token ? { token: state.token } : persistedSession(); |
| 910 | if (saved) { |
| 911 | await enterAnchorSession({ token: saved.token, created: false }); |
| 912 | return true; |
| 913 | } |
| 914 | } catch { /* fall through to the doors */ } |
| 915 | return false; |
| 916 | } |
| 917 | |
| 918 | // The email door (email.go): type your address, get a 6-digit code, type it — |
| 919 | // find-or-create like Apple. The address goes only into the two request |
| 920 | // bodies; nothing here stores it. It's a card step of its own — the doors |
| 921 | // swap out for it, same screen-swap pattern as the signed-in screens. It can |
| 922 | // be entered from the main card or the all-ways step; Back returns to |
| 923 | // whichever surface opened it. |
| 924 | let emailDoorFrom = "signed-out"; |
| 925 | function openEmailDoor() { |
| 926 | emailDoorFrom = $("otherways").classList.contains("hidden") ? "signed-out" : "otherways"; |
| 927 | $("signed-out").classList.add("hidden"); |
| 928 | $("otherways").classList.add("hidden"); |
| 929 | $("reassure").classList.add("hidden"); |
| 930 | $("email-door").classList.remove("hidden"); |
| 931 | setStatus(""); |
| 932 | $("email-addr").focus(); |
| 933 | } |
| 934 | |
| 935 | // resetEmailDoor folds the door back to its address step (shared with |
| 936 | // signOut, so the next visitor never sees a half-finished code round trip). |
| 937 | function resetEmailDoor() { |
| 938 | $("email-door").classList.add("hidden"); |
| 939 | $("email-code-form").classList.add("hidden"); |
| 940 | $("email-form").classList.remove("hidden"); |
| 941 | $("email-note").classList.remove("hidden"); |
| 942 | $("email-addr").value = ""; |
| 943 | $("email-code").value = ""; |
| 944 | } |
| 945 | |
| 946 | function closeEmailDoor() { |
| 947 | resetEmailDoor(); |
| 948 | setStatus(""); |
| 949 | if (emailDoorFrom === "otherways") { |
| 950 | // Back to the all-ways step; #signed-out stays hidden, so the reassure/ |
| 951 | // cookie/greeter visibility rules stay as the step left them. |
| 952 | $("otherways").classList.remove("hidden"); |
| 953 | return; |
| 954 | } |
| 955 | $("signed-out").classList.remove("hidden"); |
| 956 | $("reassure").classList.remove("hidden"); |
| 957 | } |
| 958 | |
| 959 | // ---- the all-ways step (#1112) -------------------------------------------------- |
| 960 | |
| 961 | // openOtherWays swaps the card to the every-door step — the same screen-swap |
| 962 | // pattern as the email door (hiding #signed-out is what keeps the greeter/ |
| 963 | // cookie/reassure rules honest). Focus lands on the first door; Back hands |
| 964 | // it back to the link. |
| 965 | function openOtherWays() { |
| 966 | $("signed-out").classList.add("hidden"); |
| 967 | $("reassure").classList.add("hidden"); |
| 968 | // The #newhere explainer follows the visible sign-in surface (revealNewhere). |
| 969 | if (!$("newhere").classList.contains("hidden")) $("otherways").append($("newhere")); |
| 970 | $("otherways").classList.remove("hidden"); |
| 971 | setStatus(""); |
| 972 | $("otherways-apple").focus(); |
| 973 | } |
| 974 | |
| 975 | function closeOtherWays() { |
| 976 | resetOtherWays(); |
| 977 | setStatus(""); |
| 978 | $("signed-out").classList.remove("hidden"); |
| 979 | $("reassure").classList.remove("hidden"); |
| 980 | $("other-ways").focus(); |
| 981 | } |
| 982 | |
| 983 | // resetOtherWays parks the all-ways step and re-homes the #newhere explainer |
| 984 | // to its markup home — shared with the arrival paths (showVault and friends), |
| 985 | // so signing in from the step never strands either piece. |
| 986 | function resetOtherWays() { |
| 987 | $("otherways").classList.add("hidden"); |
| 988 | $("signed-out").append($("newhere")); |
| 989 | } |
| 990 | |
| 991 | // revealNewhere shows the after-failure explainer on whichever sign-in |
| 992 | // surface is up — the passkey door sits on the main card only when promoted, |
| 993 | // so most ceremonies (and their failures) happen inside the all-ways step. |
| 994 | function revealNewhere() { |
| 995 | ($("otherways").classList.contains("hidden") ? $("signed-out") : $("otherways")) |
| 996 | .append($("newhere")); |
| 997 | $("newhere").classList.remove("hidden"); |
| 998 | } |
| 999 | |
| 1000 | async function sendEmailCode(e) { |
| 1001 | e.preventDefault(); |
| 1002 | const email = $("email-addr").value.trim(); |
| 1003 | setSignedOutBusy(true); |
| 1004 | setStatus("Sending your code…", "busy"); |
| 1005 | // The button spins for at least a beat even when the server is instant — |
| 1006 | // a mail send that appears to take zero time reads as a glitch, and the |
| 1007 | // sudden step-swap was jarring (Paul). The floor, not a fake: the request |
| 1008 | // runs concurrently. |
| 1009 | const sendBtn = $("email-send"); |
| 1010 | sendBtn.classList.add("loading"); |
| 1011 | const spinFloor = new Promise((r) => setTimeout(r, 900)); |
| 1012 | try { |
| 1013 | await Promise.all([apiJSON("/api/email/request", { |
| 1014 | method: "POST", |
| 1015 | headers: { "Content-Type": "application/json" }, |
| 1016 | body: JSON.stringify({ email }), |
| 1017 | }), spinFloor]); |
| 1018 | // The server answers the same 200 known-or-not (no enumeration), so this |
| 1019 | // line is honest for everyone. |
| 1020 | // The code step takes the address step's PLACE — not a row below it. |
| 1021 | setStatus("Check your email for a 6-digit code.", ""); |
| 1022 | $("email-form").classList.add("hidden"); |
| 1023 | $("email-note").classList.add("hidden"); |
| 1024 | $("email-code-form").classList.remove("hidden"); |
| 1025 | setSignedOutBusy(false); |
| 1026 | sendBtn.classList.remove("loading"); |
| 1027 | $("email-code").focus(); |
| 1028 | } catch (err) { |
| 1029 | setStatus(err.message || "Couldn’t send the code — try again.", "error"); |
| 1030 | setSignedOutBusy(false); |
| 1031 | sendBtn.classList.remove("loading"); |
| 1032 | } |
| 1033 | } |
| 1034 | |
| 1035 | async function verifyEmailCode(e) { |
| 1036 | e.preventDefault(); |
| 1037 | const email = $("email-addr").value.trim(); |
| 1038 | const code = $("email-code").value.trim(); |
| 1039 | setSignedOutBusy(true); |
| 1040 | setStatus("Checking your code…", "busy"); |
| 1041 | // Same trade as Send code, a touch longer (Paul: "it's too fast!"): the |
| 1042 | // button spins for a floor the verify races, so the signed-in landing |
| 1043 | // never swaps in before the moment has registered. |
| 1044 | const verifyBtn = $("email-verify"); |
| 1045 | verifyBtn.classList.add("loading"); |
| 1046 | const spinFloor = new Promise((r) => setTimeout(r, 1200)); |
| 1047 | try { |
| 1048 | const [res] = await Promise.all([apiJSON("/api/email/verify", { |
| 1049 | method: "POST", |
| 1050 | headers: { "Content-Type": "application/json" }, |
| 1051 | body: JSON.stringify({ email, code }), |
| 1052 | }), spinFloor]); |
| 1053 | noteProof(res.proof); // fills the confirm-step pocket (Sign-in options) |
| 1054 | markSignedIn("email"); |
| 1055 | persistSession(res.token, "email"); |
| 1056 | await enterAnchorSession(res); |
| 1057 | setSignedOutBusy(false); |
| 1058 | verifyBtn.classList.remove("loading"); |
| 1059 | } catch (err) { |
| 1060 | // The server's refusals are member-facing ("that code didn't work"). |
| 1061 | setStatus(err.message || "That code didn’t work — try again.", "error"); |
| 1062 | setSignedOutBusy(false); |
| 1063 | verifyBtn.classList.remove("loading"); |
| 1064 | } |
| 1065 | } |
| 1066 | |
| 1067 | // finishEmailLink completes the mail's magic link (the #email= boot fragment): |
| 1068 | // the address+code from the fragment — the very values the mail asks you to |
| 1069 | // type — go to the ordinary /api/email/verify, so the link shares the code's |
| 1070 | // single-use/expiry/attempt machinery and adds no auth surface of its own. |
| 1071 | // The pre-paint waiting face is up the whole time; success lands exactly like |
| 1072 | // the typed-code path, then earns the same arrival as Apple's return. |
| 1073 | async function finishEmailLink(email, code) { |
| 1074 | setStatus("Confirming it’s you…", "busy"); |
| 1075 | try { |
| 1076 | const res = await apiJSON("/api/email/verify", { |
| 1077 | method: "POST", |
| 1078 | headers: { "Content-Type": "application/json" }, |
| 1079 | body: JSON.stringify({ email, code }), |
| 1080 | }); |
| 1081 | noteProof(res.proof); // fills the confirm-step pocket (Sign-in options) |
| 1082 | markSignedIn("email"); |
| 1083 | persistSession(res.token, "email"); |
| 1084 | await enterAnchorSession(res); // renders the landing beneath the cover |
| 1085 | } catch { |
| 1086 | // An expired or already-redeemed code: no brand moment — lift straight to |
| 1087 | // the doors and say what to do next. |
| 1088 | setStatus("That sign-in link has expired — request a fresh code.", "error"); |
| 1089 | finishHomeLoader({ immediate: true }); |
| 1090 | return; |
| 1091 | } |
| 1092 | anchorArrival(); |
| 1093 | } |
| 1094 | |
| 1095 | // enterAnchorSession lands an email/Apple sign-in. The token is held exactly |
| 1096 | // like the passkey path holds its own (sessionStorage, this tab only); the |
| 1097 | // ceremony proof is not kept, matching the passkey path, which only ever uses |
| 1098 | // one transiently. An anchor authenticates but can never decrypt (no PRF), so: |
| 1099 | // fresh account or no blob yet → the normal signed-in view, empty; an |
| 1100 | // existing sealed blob → the quiet unlock-with-your-passkey prompt. |
| 1101 | async function enterAnchorSession(res) { |
| 1102 | state.token = res.token; |
| 1103 | dekKey = null; |
| 1104 | const got = res.created ? null : await fetchBlob(res.token); |
| 1105 | if (got) { |
| 1106 | vault = null; |
| 1107 | setStatus(""); |
| 1108 | showSealedVault(); |
| 1109 | return; |
| 1110 | } |
| 1111 | vault = { obj: { v: 1, servers: [] }, keys: {}, version: "0" }; |
| 1112 | setStatus(""); |
| 1113 | showVault(); |
| 1114 | } |
| 1115 | |
| 1116 | // showSealedVault is the signed-in view for an account whose list exists but |
| 1117 | // can't open here: the token works, the blob is real, and the DEK that opens |
| 1118 | // it lives behind a passkey's PRF. The prompt runs the ordinary sign-in |
| 1119 | // ceremony, which decrypts and takes over the view. |
| 1120 | function showSealedVault() { |
| 1121 | $("signed-out").classList.add("hidden"); |
| 1122 | $("reassure").classList.add("hidden"); |
| 1123 | resetEmailDoor(); // arriving from the email door hides it |
| 1124 | resetOtherWays(); // …and from the all-ways step likewise |
| 1125 | $("vault").classList.remove("hidden"); |
| 1126 | $("open-create").classList.add("hidden"); |
| 1127 | $("space-search").classList.add("hidden"); |
| 1128 | $("spaces").textContent = ""; |
| 1129 | $("vault-sealed").classList.remove("hidden"); |
| 1130 | } |
| 1131 | |
| 1132 | // ---- sign out ----------------------------------------------------------------- |
| 1133 | |
| 1134 | // signOut forgets this browser's session — the tab's token and DEK, and the |
| 1135 | // persisted stay-signed-in token — and returns to the signed-out landing. It |
| 1136 | // doesn't revoke the passkey or touch the backup: signing back in is one tap. |
| 1137 | // (Revoking a device is a separate, ceremony-gated action.) The #1112 |
| 1138 | // last-used marker deliberately survives: a returning person is still |
| 1139 | // returning. |
| 1140 | function signOut() { |
| 1141 | sessionStorage.removeItem("home_token"); |
| 1142 | sessionStorage.removeItem("home_dek"); |
| 1143 | sessionStorage.removeItem("home_cred"); |
| 1144 | clearPersistedSession(); |
| 1145 | vault = null; |
| 1146 | dekKey = null; |
| 1147 | provisioned = null; // the ledger read is per-sign-in |
| 1148 | hideVault(); |
| 1149 | $("create").classList.add("hidden"); |
| 1150 | $("create-plan").classList.add("hidden"); |
| 1151 | $("upgrade").classList.add("hidden"); |
| 1152 | $("claim").classList.add("hidden"); |
| 1153 | $("archive").classList.add("hidden"); |
| 1154 | for (const s of METHOD_SCREENS) $(s).classList.add("hidden"); |
| 1155 | resetMethodsSteps(); |
| 1156 | pendingIntent = null; |
| 1157 | combine = null; |
| 1158 | anchors = null; |
| 1159 | armedRemove = ""; |
| 1160 | dropProof(); // proofs are per-person, never per-browser |
| 1161 | archiving = null; |
| 1162 | clearUndo(); |
| 1163 | $("spaces").textContent = ""; |
| 1164 | $("vault-sealed").classList.add("hidden"); |
| 1165 | $("signed-out").classList.remove("hidden"); |
| 1166 | $("newhere").classList.add("hidden"); // fresh signed-out state, no stale explainer |
| 1167 | resetEmailDoor(); // back to the doors, address step ready for next time |
| 1168 | resetOtherWays(); |
| 1169 | composeCard(); // the last-used marker survives sign-out and may have moved the primary |
| 1170 | $("reassure").classList.remove("hidden"); |
| 1171 | setSignedOutBusy(false); |
| 1172 | setStatus("Signed out.", ""); |
| 1173 | prefetchLogin(); // ready a fresh ceremony for the next sign-in |
| 1174 | } |
| 1175 | |
| 1176 | // ---- the account menu (issue #1098's single control) --------------------------- |
| 1177 | |
| 1178 | // One kebab in the vault header collects everything about the account itself: |
| 1179 | // "Sign-in options" (the methods step family below) and Sign out; |
| 1180 | // account-merge joins it later (#1098's next slice). |
| 1181 | // Menu semantics by hand: focus lands on the first item on open and returns |
| 1182 | // to the kebab on close; Escape and click-outside both close. |
| 1183 | function accountMenuOpen() { |
| 1184 | return !$("account-menu").classList.contains("hidden"); |
| 1185 | } |
| 1186 | |
| 1187 | // A pointerdown on the kebab marks the NEXT open as pointer-born: focus |
| 1188 | // still moves into the menu (menu semantics), but the ring stays keyboard- |
| 1189 | // only — Chrome's focus-visible heuristic treats script focus as keyboard |
| 1190 | // and over-eagerly ringed mouse opens (Paul's catch). |
| 1191 | let kebabPointerOpen = false; |
| 1192 | function openAccountMenu() { |
| 1193 | $("account-menu").classList.toggle("pointer-open", kebabPointerOpen); |
| 1194 | kebabPointerOpen = false; |
| 1195 | $("account-menu").classList.remove("hidden"); |
| 1196 | $("account-menu-btn").setAttribute("aria-expanded", "true"); |
| 1197 | $("account-menu").querySelector('[role="menuitem"]').focus(); |
| 1198 | addEventListener("pointerdown", accountMenuOutside, true); |
| 1199 | addEventListener("keydown", accountMenuKey, true); |
| 1200 | } |
| 1201 | |
| 1202 | // close(refocus): refocus only for keyboard paths (Escape, the kebab toggle) — |
| 1203 | // a click elsewhere shouldn't yank focus back to the header. |
| 1204 | function closeAccountMenu(refocus) { |
| 1205 | if (!accountMenuOpen()) return; |
| 1206 | $("account-menu").classList.add("hidden"); |
| 1207 | $("account-menu-btn").setAttribute("aria-expanded", "false"); |
| 1208 | removeEventListener("pointerdown", accountMenuOutside, true); |
| 1209 | removeEventListener("keydown", accountMenuKey, true); |
| 1210 | if (refocus) $("account-menu-btn").focus(); |
| 1211 | } |
| 1212 | |
| 1213 | function accountMenuOutside(e) { |
| 1214 | if (!e.target.closest || !e.target.closest(".kebab-wrap")) closeAccountMenu(false); |
| 1215 | } |
| 1216 | |
| 1217 | function accountMenuKey(e) { |
| 1218 | if (e.key === "Escape") { e.preventDefault(); closeAccountMenu(true); } |
| 1219 | } |
| 1220 | |
| 1221 | // ---- sign-in options (issue #1098) ---------------------------------------------- |
| 1222 | |
| 1223 | // The ceremony-proof pocket. Removing or adding a sign-in method needs a |
| 1224 | // FRESH proof (the server holds them to a 5-minute window — stateless.go's |
| 1225 | // ceremonyWindow); every ceremony that mints one drops it here, and |
| 1226 | // freshProof answers only while a client-side mirror of that window still |
| 1227 | // has margin, so a call never leaves with a proof that will be refused on |
| 1228 | // arrival. The pocket mirrors into sessionStorage — same tab, like the |
| 1229 | // round-trip markers — because adding Apple or an OIDC provider IS a |
| 1230 | // full-page navigation: without the mirror, every return leg came back to |
| 1231 | // an empty pocket and the next change re-demanded a ceremony inside the |
| 1232 | // same 5-minute window. Sign-out drops it (proofs are per-person). |
| 1233 | const PROOF_TTL_MS = 5 * 60 * 1000; |
| 1234 | const PROOF_MARGIN_MS = 10 * 1000; |
| 1235 | let ceremonyProof = null; // { proof, expires } | null |
| 1236 | function noteProof(proof, expires) { |
| 1237 | if (!proof) return; |
| 1238 | ceremonyProof = { proof, expires: expires || Date.now() + PROOF_TTL_MS }; |
| 1239 | try { sessionStorage.setItem("home_proof", JSON.stringify(ceremonyProof)); } catch { /* memory still holds it */ } |
| 1240 | } |
| 1241 | function dropProof() { |
| 1242 | ceremonyProof = null; |
| 1243 | try { sessionStorage.removeItem("home_proof"); } catch { /* nothing stored */ } |
| 1244 | } |
| 1245 | function freshProof() { |
| 1246 | if (!ceremonyProof) { |
| 1247 | try { ceremonyProof = JSON.parse(sessionStorage.getItem("home_proof") || "null"); } catch { /* mangled — an empty pocket */ } |
| 1248 | } |
| 1249 | return ceremonyProof && ceremonyProof.expires - PROOF_MARGIN_MS > Date.now() |
| 1250 | ? ceremonyProof.proof : null; |
| 1251 | } |
| 1252 | |
| 1253 | // The kind vocabulary, generalized: every sign-in method the product knows, |
| 1254 | // in listing order. A linked kind's ADD lives in each of its rows' kebab (a |
| 1255 | // plain "Add another" — the row already names the kind); a kind with ZERO |
| 1256 | // linked entries gets a big first-add button below the list instead. Future |
| 1257 | // doors (google/microsoft) slot in here — same shape, gated by their DOORS |
| 1258 | // flag — with no restructuring. |
| 1259 | const METHOD_KINDS = [ |
| 1260 | { kind: "passkey", name: "Passkey", first: "Add a passkey", |
| 1261 | enabled: () => !!window.PublicKeyCredential, start: (e) => startAddPasskey(e) }, |
| 1262 | { kind: "siwa", name: "Apple", first: "Add Apple", |
| 1263 | enabled: () => DOORS.apple, start: (e) => startAddApple(e) }, |
| 1264 | { kind: "github", name: "GitHub", first: "Add GitHub", |
| 1265 | enabled: () => DOORS.github, start: (e) => startAddOidc("github", e) }, |
| 1266 | { kind: "microsoft", name: "Microsoft", first: "Add Microsoft", |
| 1267 | enabled: () => DOORS.microsoft, start: (e) => startAddOidc("microsoft", e) }, |
| 1268 | { kind: "google", name: "Google", first: "Add Google", |
| 1269 | enabled: () => DOORS.google, start: (e) => startAddGoogle(e) }, |
| 1270 | { kind: "email", name: "Email", first: "Add email", |
| 1271 | enabled: () => DOORS.email, start: (e) => startAddEmail(e) }, |
| 1272 | ]; |
| 1273 | |
| 1274 | // The step family is SERIAL card screens — one at a time, each with a back: |
| 1275 | // the list, the confirm beat, add-email, add-passkey. (Add-Apple's "step" is |
| 1276 | // the Apple journey itself.) showMethodsScreen swaps within the family; |
| 1277 | // callers manage focus. |
| 1278 | const METHOD_SCREENS = ["methods", "methods-confirm", "methods-email", "methods-passkey", "methods-combine"]; |
| 1279 | function showMethodsScreen(id) { |
| 1280 | closeRowMenu(false); |
| 1281 | for (const s of METHOD_SCREENS) $(s).classList.toggle("hidden", s !== id); |
| 1282 | } |
| 1283 | |
| 1284 | function methodsStatus(msg, kind) { |
| 1285 | const el = $("methods-status"); |
| 1286 | el.textContent = msg || ""; |
| 1287 | el.className = "status" + (kind ? " " + kind : ""); |
| 1288 | } |
| 1289 | function confirmStatus(msg, kind) { |
| 1290 | const el = $("methods-confirm-status"); |
| 1291 | el.textContent = msg || ""; |
| 1292 | el.className = "status" + (kind ? " " + kind : ""); |
| 1293 | } |
| 1294 | function emailStepStatus(msg, kind) { |
| 1295 | const el = $("methods-email-status"); |
| 1296 | el.textContent = msg || ""; |
| 1297 | el.className = "status" + (kind ? " " + kind : ""); |
| 1298 | } |
| 1299 | function passkeyStepStatus(msg, kind) { |
| 1300 | const el = $("methods-passkey-status"); |
| 1301 | el.textContent = msg || ""; |
| 1302 | el.className = "status" + (kind ? " " + kind : ""); |
| 1303 | } |
| 1304 | |
| 1305 | // anchors is the last-fetched listing (GET /api/anchors) — renderMethods' |
| 1306 | // input, and what the confirm step consults to pick a ceremony this account |
| 1307 | // can actually run. armedRemove holds the row currently in its inline |
| 1308 | // are-you-sure state, so re-renders keep it armed. |
| 1309 | let anchors = null; |
| 1310 | let armedRemove = ""; |
| 1311 | // pendingIntent is the change the confirm step interrupted, AS DATA — the one |
| 1312 | // continuation system behind every confirm path. In-page ceremonies resume it |
| 1313 | // from memory (payloads intact); the confirm-with-Apple round trip carries |
| 1314 | // its intentToken through sessionStorage and the return leg resumes the same |
| 1315 | // way. Shapes: { kind: "add-email" | "add-passkey" | "add-apple" }, { kind: |
| 1316 | // "verify-email", email, code } (memory-only payload), { kind: "remove", |
| 1317 | // anchorKind, id, armed }. |
| 1318 | let pendingIntent = null; |
| 1319 | |
| 1320 | // intentToken / parseIntentToken serialize a pending intent for the Apple |
| 1321 | // confirmation's full-page round trip (sessionStorage, set-or-cleared per |
| 1322 | // trip like the other markers). Only the change's IDENTITY rides — never a |
| 1323 | // payload: a typed address/code stays in memory, so "verify-email" degrades |
| 1324 | // to reopening the add-email step (the same mailed code can be retyped |
| 1325 | // there), and a removal's passed are-you-sure is deliberately dropped (a |
| 1326 | // reloaded page re-arms rather than auto-deleting). Anchor ids are |
| 1327 | // base64url/hex, so ":" can't ambiguate the remove form. |
| 1328 | function intentToken(intent) { |
| 1329 | if (!intent) return ""; |
| 1330 | if (intent.kind === "remove") return "remove:" + intent.anchorKind + ":" + intent.id; |
| 1331 | if (intent.kind === "verify-email") return "add-email"; |
| 1332 | if (intent.kind === "combine-finish") return ""; // the combine context is memory-only |
| 1333 | return intent.kind; // add-email | add-passkey | add-apple |
| 1334 | } |
| 1335 | function parseIntentToken(s) { |
| 1336 | if (["add-email", "add-passkey", "add-apple", "add-google", "add-microsoft", "add-github"].includes(s)) return { kind: s }; |
| 1337 | const m = /^remove:([^:]+):(.+)$/.exec(s || ""); |
| 1338 | return m ? { kind: "remove", anchorKind: m[1], id: m[2] } : null; |
| 1339 | } |
| 1340 | |
| 1341 | // runIntent performs a parked change once a fresh proof is in hand. Callers |
| 1342 | // guarantee the pocket is fresh; the intent decides its own navigation. |
| 1343 | function runIntent(intent, proof) { |
| 1344 | switch (intent.kind) { |
| 1345 | case "add-email": openMethodsEmailStep(); return; |
| 1346 | case "add-passkey": openMethodsPasskeyStep(); return; |
| 1347 | case "add-apple": startAddApple(null); return; // pocket is fresh — straight to Apple in link mode |
| 1348 | case "add-google": startAddOidc("google", null); return; |
| 1349 | case "add-microsoft": startAddOidc("microsoft", null); return; |
| 1350 | case "add-github": startAddOidc("github", null); return; |
| 1351 | case "verify-email": |
| 1352 | // In-memory only (never serialized): retry the exact typed code — it |
| 1353 | // survived, link mode gates on the proof BEFORE redeeming (email.go). |
| 1354 | backToMethodsList("", "", { reload: false }); |
| 1355 | finishEmailAdd(intent.email, intent.code, proof); |
| 1356 | return; |
| 1357 | case "combine-finish": |
| 1358 | // In-memory only: the survivor's proof lapsed mid-combine; finish now. |
| 1359 | combineFinish(intent.movedSpaces || 0); |
| 1360 | return; |
| 1361 | case "remove": { |
| 1362 | const a = (anchors || []).find((x) => x.kind === intent.anchorKind && x.id === intent.id); |
| 1363 | backToMethodsList("", "", { reload: !a }); // row gone meanwhile → just the fresh list |
| 1364 | if (!a) return; |
| 1365 | if (intent.armed) { |
| 1366 | // The are-you-sure was passed right before an in-page confirm — |
| 1367 | // complete the removal. |
| 1368 | doRemoveAnchor(a, proof); |
| 1369 | } else { |
| 1370 | // The Apple round trip reloaded the page: the destructive tap stays |
| 1371 | // explicit — re-arm THAT row's are-you-sure instead of auto-deleting. |
| 1372 | armedRemove = intent.anchorKind + "/" + intent.id; |
| 1373 | renderMethods(); |
| 1374 | focusMethodRow(armedRemove, "button.danger-ink"); |
| 1375 | } |
| 1376 | return; |
| 1377 | } |
| 1378 | } |
| 1379 | } |
| 1380 | |
| 1381 | async function openMethods() { |
| 1382 | hideVault(); |
| 1383 | resetMethodsSteps(); |
| 1384 | armedRemove = ""; |
| 1385 | pendingIntent = null; |
| 1386 | methodsStatus(""); |
| 1387 | showMethodsScreen("methods"); |
| 1388 | $("methods-title").focus(); // announce the step; rows load in beneath |
| 1389 | await loadAnchors(); |
| 1390 | } |
| 1391 | |
| 1392 | function closeMethods() { |
| 1393 | resetMethodsSteps(); |
| 1394 | pendingIntent = null; |
| 1395 | combine = null; // abandoning the flow touches neither account |
| 1396 | for (const s of METHOD_SCREENS) $(s).classList.add("hidden"); |
| 1397 | // Back to whichever signed-in view this session actually has — anchor-born |
| 1398 | // sessions may still be sealed (no vault object to render). |
| 1399 | if (vault) showVault(); else showSealedVault(); |
| 1400 | } |
| 1401 | |
| 1402 | // backToMethodsList returns from a sub-step (or finishes a flow) to the |
| 1403 | // list, with the outcome line where the person lands. |
| 1404 | function backToMethodsList(msg, kind, { reload = true } = {}) { |
| 1405 | showMethodsScreen("methods"); |
| 1406 | $("methods-title").focus(); |
| 1407 | methodsStatus(msg || "", kind); |
| 1408 | if (reload) loadAnchors(); |
| 1409 | } |
| 1410 | |
| 1411 | // resetMethodsSteps parks every sub-step — shared by open/close/sign-out so |
| 1412 | // no half-finished round trip greets the next visit. |
| 1413 | function resetMethodsSteps() { |
| 1414 | // The confirm step. |
| 1415 | $("methods-confirm-passkey").classList.add("hidden"); |
| 1416 | $("methods-confirm-apple").classList.add("hidden"); |
| 1417 | $("methods-confirm-google").classList.add("hidden"); |
| 1418 | $("methods-confirm-microsoft").classList.add("hidden"); |
| 1419 | $("methods-confirm-github").classList.add("hidden"); |
| 1420 | $("methods-confirm-email").classList.add("hidden"); |
| 1421 | $("methods-cemail").classList.add("hidden"); |
| 1422 | $("methods-cemail-form").classList.remove("hidden"); |
| 1423 | $("methods-cemail-code-form").classList.add("hidden"); |
| 1424 | $("methods-cemail-addr").value = ""; |
| 1425 | $("methods-cemail-code").value = ""; |
| 1426 | confirmStatus(""); |
| 1427 | // The add-email step. |
| 1428 | $("methods-email-form").classList.remove("hidden"); |
| 1429 | $("methods-email-code-form").classList.add("hidden"); |
| 1430 | $("methods-email-addr").value = ""; |
| 1431 | $("methods-email-code").value = ""; |
| 1432 | emailStepStatus(""); |
| 1433 | // The add-passkey step. |
| 1434 | passkeyAddBegin = null; |
| 1435 | passkeyStepStatus(""); |
| 1436 | // The combine step's stages + the 409 offer (the combine CONTEXT itself |
| 1437 | // survives — only closing the family or finishing clears it). |
| 1438 | $("combine-offer").classList.add("hidden"); |
| 1439 | $("combine-prove").classList.add("hidden"); |
| 1440 | $("combine-code").classList.add("hidden"); |
| 1441 | $("combine-apple-go").classList.add("hidden"); |
| 1442 | $("combine-oidc-go").classList.add("hidden"); |
| 1443 | $("combine-code-input").value = ""; |
| 1444 | $("combine-vault").classList.add("hidden"); |
| 1445 | $("combine-unlock").classList.add("hidden"); |
| 1446 | $("combine-skip").classList.add("hidden"); |
| 1447 | $("combine-skip-confirm").classList.add("hidden"); |
| 1448 | combineStatus(""); |
| 1449 | } |
| 1450 | |
| 1451 | async function loadAnchors() { |
| 1452 | try { |
| 1453 | const r = await apiJSON("/api/anchors", { headers: { Authorization: "Bearer " + state.token } }); |
| 1454 | anchors = r.anchors || []; |
| 1455 | renderMethods(); |
| 1456 | } catch (e) { |
| 1457 | methodsStatus(e.message || "Couldn't load your sign-in options — try again.", "error"); |
| 1458 | } |
| 1459 | } |
| 1460 | |
| 1461 | // methodAdded is the row's quiet second line — "added 12 Jul", year only |
| 1462 | // when it isn't this year's. |
| 1463 | function methodAdded(ts) { |
| 1464 | if (!ts) return ""; |
| 1465 | const d = new Date(ts * 1000); |
| 1466 | const opts = { day: "numeric", month: "short" }; |
| 1467 | if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric"; |
| 1468 | return "added " + d.toLocaleDateString(undefined, opts); |
| 1469 | } |
| 1470 | |
| 1471 | // ---- method labels -------------------------------------------------------------- |
| 1472 | |
| 1473 | // A method row's human label ("personal", the email address) is E2EE state, |
| 1474 | // never server metadata: vault.obj.methods = { "<kind>/<id>": { label } }, |
| 1475 | // keyed the way the anchors listing names rows, riding the sealed blob like |
| 1476 | // space names do. Sealed sessions (no DEK) neither show nor offer labels. |
| 1477 | let editingLabel = ""; |
| 1478 | |
| 1479 | function canLabel() { return !!(vault && dekKey); } |
| 1480 | |
| 1481 | function methodLabel(key) { |
| 1482 | const m = vault && vault.obj && vault.obj.methods; |
| 1483 | return (m && m[key] && m[key].label) || ""; |
| 1484 | } |
| 1485 | |
| 1486 | async function setMethodLabel(key, label) { |
| 1487 | if (!canLabel()) return; |
| 1488 | const m = { ...(vault.obj.methods || {}) }; |
| 1489 | if (label) m[key] = { label }; else delete m[key]; |
| 1490 | if (Object.keys(m).length) vault.obj.methods = m; else delete vault.obj.methods; |
| 1491 | await saveVault(); |
| 1492 | } |
| 1493 | |
| 1494 | // labelNewEmail stores a just-verified address as its anchor's label. The |
| 1495 | // client can't compute the anchor id (that's the server's peppered blind |
| 1496 | // index), so diff the listing: the email id that wasn't there before the |
| 1497 | // add is the one just created. |
| 1498 | async function labelNewEmail(email) { |
| 1499 | if (!canLabel()) return; |
| 1500 | const before = new Set((anchors || []).filter((x) => x.kind === "email").map((x) => x.id)); |
| 1501 | try { |
| 1502 | const r = await apiJSON("/api/anchors", { headers: { Authorization: "Bearer " + state.token } }); |
| 1503 | const fresh = (r.anchors || []).filter((x) => x.kind === "email" && !before.has(x.id)); |
| 1504 | if (fresh.length === 1) { await setMethodLabel("email/" + fresh[0].id, email); renderMethods(); } |
| 1505 | } catch { /* the label is a nicety — the anchor itself is already in */ } |
| 1506 | } |
| 1507 | |
| 1508 | function renderMethods() { |
| 1509 | closeRowMenu(false); |
| 1510 | const box = $("methods-list"); |
| 1511 | box.textContent = ""; |
| 1512 | if (anchors && anchors.length) { |
| 1513 | const ul = document.createElement("ul"); |
| 1514 | ul.className = "methods"; |
| 1515 | for (const a of anchors) ul.append(methodRow(a)); |
| 1516 | box.append(ul); |
| 1517 | } else if (anchors) { |
| 1518 | // Can't normally happen (a signed-in account always has a way in) — a |
| 1519 | // racing removal from another device can strand a beat, though. |
| 1520 | box.append(emptyLine("No sign-in methods to show.")); |
| 1521 | } |
| 1522 | renderMethodsAdd(); |
| 1523 | } |
| 1524 | |
| 1525 | // renderMethodsAdd builds the big first-add buttons: one per LIVE kind with |
| 1526 | // zero linked entries (a linked kind's add lives in its rows' kebab). |
| 1527 | function renderMethodsAdd() { |
| 1528 | const box = $("methods-add"); |
| 1529 | box.textContent = ""; |
| 1530 | for (const k of METHOD_KINDS) { |
| 1531 | if (!k.enabled()) continue; |
| 1532 | if ((anchors || []).some((a) => a.kind === k.kind)) continue; |
| 1533 | const btn = document.createElement("button"); |
| 1534 | btn.type = "button"; |
| 1535 | btn.className = "secondary"; |
| 1536 | btn.textContent = k.first; |
| 1537 | const glyph = kindGlyph(k.kind); |
| 1538 | if (glyph) btn.prepend(glyph); |
| 1539 | btn.addEventListener("click", (e) => k.start(e)); |
| 1540 | box.append(btn); |
| 1541 | } |
| 1542 | } |
| 1543 | |
| 1544 | // The same three dots as the account kebab (static, in-code SVG). |
| 1545 | const KEBAB_SVG = |
| 1546 | '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">' + |
| 1547 | '<circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg>'; |
| 1548 | |
| 1549 | function methodRow(a) { |
| 1550 | const li = document.createElement("li"); |
| 1551 | const key = a.kind + "/" + a.id; |
| 1552 | li.dataset.key = key; |
| 1553 | const def = METHOD_KINDS.find((k) => k.kind === a.kind); |
| 1554 | const label = def ? def.name : a.kind; |
| 1555 | const tag = methodLabel(key); // "" when unlabelled, or the vault is sealed |
| 1556 | const added = methodAdded(a.created_at); |
| 1557 | // A labelled row leads with the label — the distinguisher earns the bold |
| 1558 | // ("1Password Eleven key" over "Passkey · added 3 Jul"); unlabelled rows |
| 1559 | // keep the kind on top. The aria name reads in the same order. |
| 1560 | const longName = (tag ? tag + ", " + label : label) + (added ? ", " + added : ""); |
| 1561 | |
| 1562 | const main = document.createElement("div"); |
| 1563 | main.className = "method-main"; |
| 1564 | const name = document.createElement("span"); |
| 1565 | name.className = "method-name"; |
| 1566 | name.textContent = tag || label; |
| 1567 | main.append(name); |
| 1568 | if (editingLabel === key) { |
| 1569 | main.append(labelEditor(key, tag, longName)); |
| 1570 | } else { |
| 1571 | const subText = (tag ? [label, added] : [added]).filter(Boolean).join(" · "); |
| 1572 | if (subText) { |
| 1573 | const sub = document.createElement("span"); |
| 1574 | sub.className = "method-added"; |
| 1575 | sub.textContent = subText; |
| 1576 | main.append(sub); |
| 1577 | } |
| 1578 | } |
| 1579 | li.append(main); |
| 1580 | |
| 1581 | if (armedRemove === key) { |
| 1582 | // The inline are-you-sure beat (never a browser confirm()): the row's |
| 1583 | // own controls become the question. |
| 1584 | const yes = document.createElement("button"); |
| 1585 | yes.type = "button"; |
| 1586 | yes.className = "pill danger-ink"; |
| 1587 | yes.textContent = "Remove"; |
| 1588 | yes.setAttribute("aria-label", "Yes, remove " + longName); |
| 1589 | yes.addEventListener("click", () => { |
| 1590 | armedRemove = ""; |
| 1591 | renderMethods(); // disarm before the (async) removal repaints |
| 1592 | focusMethodRow(key, "button"); // keep focus off <body> mid-flight |
| 1593 | performRemove(a); |
| 1594 | }); |
| 1595 | const no = document.createElement("button"); |
| 1596 | no.type = "button"; |
| 1597 | no.className = "pill"; |
| 1598 | no.textContent = "Keep"; |
| 1599 | no.setAttribute("aria-label", "Keep " + longName); |
| 1600 | no.addEventListener("click", () => { |
| 1601 | armedRemove = ""; |
| 1602 | renderMethods(); |
| 1603 | focusMethodRow(key, "button"); |
| 1604 | }); |
| 1605 | li.append(yes, no); |
| 1606 | } else if (editingLabel !== key) { |
| 1607 | li.append(rowKebab(key, def, longName, a)); |
| 1608 | } |
| 1609 | return li; |
| 1610 | } |
| 1611 | |
| 1612 | // The inline label editor (never a browser prompt): the row's sub-line |
| 1613 | // becomes a small form. Enter saves, Escape or Cancel keeps the old label. |
| 1614 | function labelEditor(key, current, longName) { |
| 1615 | const form = document.createElement("form"); |
| 1616 | form.className = "label-edit"; |
| 1617 | const input = document.createElement("input"); |
| 1618 | input.type = "text"; |
| 1619 | input.maxLength = 60; |
| 1620 | input.value = current; |
| 1621 | input.placeholder = "e.g. personal, work"; |
| 1622 | input.setAttribute("aria-label", "Label for " + longName); |
| 1623 | const save = document.createElement("button"); |
| 1624 | save.type = "submit"; |
| 1625 | save.className = "pill"; |
| 1626 | save.textContent = "Save"; |
| 1627 | const cancel = document.createElement("button"); |
| 1628 | cancel.type = "button"; |
| 1629 | cancel.className = "pill"; |
| 1630 | cancel.textContent = "Cancel"; |
| 1631 | cancel.setAttribute("aria-label", "Cancel editing the label for " + longName); |
| 1632 | const done = () => { editingLabel = ""; renderMethods(); focusMethodRow(key, ".icon-kebab"); }; |
| 1633 | form.addEventListener("submit", async (e) => { |
| 1634 | e.preventDefault(); |
| 1635 | save.disabled = cancel.disabled = true; |
| 1636 | try { |
| 1637 | await setMethodLabel(key, input.value.trim()); |
| 1638 | done(); |
| 1639 | } catch { |
| 1640 | save.disabled = cancel.disabled = false; |
| 1641 | methodsStatus("Couldn’t save the label — try again.", "error"); |
| 1642 | } |
| 1643 | }); |
| 1644 | cancel.addEventListener("click", done); |
| 1645 | input.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.stopPropagation(); done(); } }); |
| 1646 | form.append(input, save, cancel); |
| 1647 | return form; |
| 1648 | } |
| 1649 | |
| 1650 | // rowKebab is the row's own menu — the account kebab's vocabulary scaled |
| 1651 | // down: aria-haspopup/expanded, focus onto the first item, Escape and |
| 1652 | // outside-click both close (the shared manager keeps one open at a time). |
| 1653 | // It holds "Add another" (when the kind's door is live) and Remove. |
| 1654 | function rowKebab(key, def, longName, a) { |
| 1655 | const wrap = document.createElement("div"); |
| 1656 | wrap.className = "row-kebab-wrap"; |
| 1657 | const btn = document.createElement("button"); |
| 1658 | btn.type = "button"; |
| 1659 | btn.className = "icon-kebab"; |
| 1660 | btn.setAttribute("aria-haspopup", "menu"); |
| 1661 | btn.setAttribute("aria-expanded", "false"); |
| 1662 | btn.setAttribute("aria-label", "Options for " + longName); |
| 1663 | btn.innerHTML = KEBAB_SVG; |
| 1664 | const menu = document.createElement("div"); |
| 1665 | menu.className = "menu-pop hidden"; |
| 1666 | menu.setAttribute("role", "menu"); |
| 1667 | menu.setAttribute("aria-label", "Options for " + longName); |
| 1668 | if (def && def.enabled()) { |
| 1669 | const add = document.createElement("button"); |
| 1670 | add.type = "button"; |
| 1671 | add.setAttribute("role", "menuitem"); |
| 1672 | // Visibly just "Add another" — the row names the kind. The accessible |
| 1673 | // name carries it for menus read out of visual context ("Add another |
| 1674 | // Passkey"); label-in-name holds, the visible text is its prefix. |
| 1675 | add.textContent = "Add another"; |
| 1676 | add.setAttribute("aria-label", "Add another " + (def.name || a.kind)); |
| 1677 | add.addEventListener("click", (e) => { closeRowMenu(false); def.start(e); }); |
| 1678 | menu.append(add); |
| 1679 | } |
| 1680 | if (canLabel()) { |
| 1681 | const lab = document.createElement("button"); |
| 1682 | lab.type = "button"; |
| 1683 | lab.setAttribute("role", "menuitem"); |
| 1684 | lab.textContent = methodLabel(key) ? "Edit label" : "Add a label"; |
| 1685 | lab.setAttribute("aria-label", (methodLabel(key) ? "Edit the label for " : "Add a label for ") + longName); |
| 1686 | lab.addEventListener("click", () => { |
| 1687 | closeRowMenu(false); |
| 1688 | editingLabel = key; |
| 1689 | armedRemove = ""; |
| 1690 | renderMethods(); |
| 1691 | focusMethodRow(key, "input"); |
| 1692 | }); |
| 1693 | menu.append(lab); |
| 1694 | } |
| 1695 | const rm = document.createElement("button"); |
| 1696 | rm.type = "button"; |
| 1697 | rm.setAttribute("role", "menuitem"); |
| 1698 | rm.className = "danger-ink"; |
| 1699 | rm.textContent = "Remove"; |
| 1700 | rm.setAttribute("aria-label", "Remove " + longName); |
| 1701 | rm.addEventListener("click", () => { |
| 1702 | closeRowMenu(false); |
| 1703 | armedRemove = key; |
| 1704 | editingLabel = ""; |
| 1705 | renderMethods(); |
| 1706 | focusMethodRow(key, "button.danger-ink"); // the are-you-sure's Remove |
| 1707 | }); |
| 1708 | menu.append(rm); |
| 1709 | btn.addEventListener("pointerdown", () => { rowMenuPointerOpen = true; }); |
| 1710 | btn.addEventListener("click", () => toggleRowMenu(btn, menu)); |
| 1711 | wrap.append(btn, menu); |
| 1712 | return wrap; |
| 1713 | } |
| 1714 | |
| 1715 | // One row menu open at a time, with the account menu's open/close manners |
| 1716 | // (including its pointer-open focus-ring courtesy). |
| 1717 | let openRowMenuState = null; // { btn, menu } | null |
| 1718 | let rowMenuPointerOpen = false; |
| 1719 | function toggleRowMenu(btn, menu) { |
| 1720 | if (openRowMenuState && openRowMenuState.menu === menu) { closeRowMenu(true); return; } |
| 1721 | closeRowMenu(false); |
| 1722 | menu.classList.toggle("pointer-open", rowMenuPointerOpen); |
| 1723 | rowMenuPointerOpen = false; |
| 1724 | menu.classList.remove("hidden"); |
| 1725 | btn.setAttribute("aria-expanded", "true"); |
| 1726 | openRowMenuState = { btn, menu }; |
| 1727 | menu.querySelector('[role="menuitem"]').focus(); |
| 1728 | addEventListener("pointerdown", rowMenuOutside, true); |
| 1729 | addEventListener("keydown", rowMenuKey, true); |
| 1730 | } |
| 1731 | function closeRowMenu(refocus) { |
| 1732 | if (!openRowMenuState) return; |
| 1733 | openRowMenuState.menu.classList.add("hidden"); |
| 1734 | openRowMenuState.btn.setAttribute("aria-expanded", "false"); |
| 1735 | if (refocus) openRowMenuState.btn.focus(); |
| 1736 | openRowMenuState = null; |
| 1737 | removeEventListener("pointerdown", rowMenuOutside, true); |
| 1738 | removeEventListener("keydown", rowMenuKey, true); |
| 1739 | } |
| 1740 | function rowMenuOutside(e) { |
| 1741 | if (!e.target.closest || !e.target.closest(".row-kebab-wrap")) closeRowMenu(false); |
| 1742 | } |
| 1743 | function rowMenuKey(e) { |
| 1744 | if (e.key === "Escape") { e.preventDefault(); closeRowMenu(true); } |
| 1745 | } |
| 1746 | |
| 1747 | // focusMethodRow puts focus back where a re-render dropped it. |
| 1748 | function focusMethodRow(key, selector) { |
| 1749 | for (const li of $("methods-list").querySelectorAll("li")) { |
| 1750 | if (li.dataset.key === key) { li.querySelector(selector)?.focus(); return; } |
| 1751 | } |
| 1752 | } |
| 1753 | |
| 1754 | // performRemove: a fresh proof first — SERIALLY, via the confirm step when |
| 1755 | // the pocket is empty (list → confirm → back to the list → the DELETE) — |
| 1756 | // then doRemoveAnchor. The server's refusals — notably the last-method 409 |
| 1757 | // ("that's the last way into this account…") — show inline, verbatim. |
| 1758 | function performRemove(a) { |
| 1759 | const p = freshProof(); |
| 1760 | if (p) { doRemoveAnchor(a, p); return; } |
| 1761 | // armed: the are-you-sure was just passed, so an in-page confirm completes |
| 1762 | // the removal; the round-trip serialization drops the flag on purpose. |
| 1763 | pendingIntent = { kind: "remove", anchorKind: a.kind, id: a.id, armed: true }; |
| 1764 | openMethodsConfirmStep(); |
| 1765 | } |
| 1766 | |
| 1767 | async function doRemoveAnchor(a, proof) { |
| 1768 | methodsStatus("Removing…", "busy"); |
| 1769 | try { |
| 1770 | await apiJSON("/api/anchors/" + encodeURIComponent(a.kind) + "/" + encodeURIComponent(a.id), { |
| 1771 | method: "DELETE", |
| 1772 | headers: { "X-Ceremony-Proof": proof }, |
| 1773 | }); |
| 1774 | methodsStatus("Removed.", "ok"); |
| 1775 | setMethodLabel(a.kind + "/" + a.id, "").catch(() => { /* orphaned label — harmless */ }); |
| 1776 | } catch (e) { |
| 1777 | methodsStatus((e && e.message) || "Couldn't remove it.", "error"); |
| 1778 | } |
| 1779 | await loadAnchors(); // either way — a refusal means the list needs truth |
| 1780 | // The focused row may just have vanished — never strand focus on <body>. |
| 1781 | if (document.activeElement === document.body) $("methods-title").focus(); |
| 1782 | } |
| 1783 | |
| 1784 | function resumeIntent() { |
| 1785 | const intent = pendingIntent; |
| 1786 | pendingIntent = null; |
| 1787 | const p = freshProof(); |
| 1788 | if (intent && p) runIntent(intent, p); |
| 1789 | } |
| 1790 | |
| 1791 | // ---- the confirm step ----------------------------------------------------------- |
| 1792 | |
| 1793 | // The confirm rungs, in fallback order; last-used (the #1112 marker) is |
| 1794 | // promoted to the front. Each rung answers "can this account actually run |
| 1795 | // it" from the anchors listing — the server takes a fresh proof from ANY |
| 1796 | // door equally (stateless.go's ceremonyUser), so the step offers every one |
| 1797 | // the account has, never passkey-or-bust. Apple doubles as the fallback for |
| 1798 | // an account whose listing hasn't loaded (the pre-anchors shape). |
| 1799 | const CONFIRM_RUNGS = ["passkey", "apple", "google", "microsoft", "github", "email"]; |
| 1800 | function confirmRungAvailable(rung) { |
| 1801 | const kinds = new Set((anchors || []).map((x) => x.kind)); |
| 1802 | if (rung === "passkey") return !!window.PublicKeyCredential && kinds.has("passkey"); |
| 1803 | if (rung === "apple") return DOORS.apple && kinds.has("siwa"); |
| 1804 | // google | microsoft | github | email — anchor kinds verbatim, gated by the |
| 1805 | // door being LIVE on this build (a dark door can't run its ceremony). |
| 1806 | return DOORS[rung] && kinds.has(rung); |
| 1807 | } |
| 1808 | |
| 1809 | // openMethodsConfirmStep is the serial "it's really you" screen, FIRST |
| 1810 | // whenever an action needs a proof the pocket doesn't hold. Every door the |
| 1811 | // account has shows, last-used first; the email rung unfolds into its code |
| 1812 | // mini-form (immediately when it's the only rung — no pointless extra tap). |
| 1813 | function openMethodsConfirmStep(note) { |
| 1814 | resetMethodsSteps(); // fresh forms; the parked change rides pendingIntent |
| 1815 | showMethodsScreen("methods-confirm"); |
| 1816 | let rungs = CONFIRM_RUNGS.filter(confirmRungAvailable); |
| 1817 | if (!rungs.length) rungs = ["apple"]; |
| 1818 | let last = ""; |
| 1819 | try { last = localStorage.getItem("home_last_signin") || ""; } catch { /* fallback order stands */ } |
| 1820 | if (rungs.includes(last)) rungs = [last, ...rungs.filter((r) => r !== last)]; |
| 1821 | const btns = { |
| 1822 | passkey: $("methods-confirm-passkey"), apple: $("methods-confirm-apple"), |
| 1823 | google: $("methods-confirm-google"), microsoft: $("methods-confirm-microsoft"), |
| 1824 | github: $("methods-confirm-github"), email: $("methods-confirm-email"), |
| 1825 | }; |
| 1826 | for (const [rung, btn] of Object.entries(btns)) btn.classList.toggle("hidden", !rungs.includes(rung)); |
| 1827 | // DOM order is focus order — append MOVES the wired nodes into rung order. |
| 1828 | for (const rung of rungs) $("methods-confirm-actions").append(btns[rung]); |
| 1829 | const why = $("methods-confirm-why"); |
| 1830 | if (note) confirmStatus(note, "error"); |
| 1831 | if (rungs.length === 1 && rungs[0] === "email") { |
| 1832 | btns.email.classList.add("hidden"); // the one rung — straight to its form |
| 1833 | why.textContent = "Changing your sign-in options needs a quick check — enter the email address you sign in with and we'll send a code."; |
| 1834 | $("methods-cemail").classList.remove("hidden"); |
| 1835 | $("methods-cemail-addr").focus(); |
| 1836 | } else { |
| 1837 | why.textContent = rungs.length === 1 |
| 1838 | ? "Changing your sign-in options needs a quick check — confirm it's you and you'll carry straight on." |
| 1839 | : "Changing your sign-in options needs a quick check — confirm it's you with any of these and you'll carry straight on."; |
| 1840 | btns[rungs[0]].focus(); |
| 1841 | } |
| 1842 | } |
| 1843 | |
| 1844 | // The email rung's tap unfolds the code mini-form in place — the other |
| 1845 | // rungs stay, so a mind-change needs no back-and-forth. |
| 1846 | function confirmMethodsEmail() { |
| 1847 | $("methods-confirm-email").classList.add("hidden"); |
| 1848 | $("methods-cemail").classList.remove("hidden"); |
| 1849 | $("methods-cemail-addr").focus(); |
| 1850 | } |
| 1851 | |
| 1852 | async function confirmMethodsPasskey() { |
| 1853 | const btn = $("methods-confirm-passkey"); |
| 1854 | btn.disabled = true; |
| 1855 | confirmStatus("Confirming it’s you…", "busy"); |
| 1856 | try { |
| 1857 | // Only the proof is kept — this tab's session stays as it is (picking a |
| 1858 | // different account's passkey just makes the follow-on call miss, said |
| 1859 | // inline on the list). |
| 1860 | const { proof } = await runLogin(loginBegin); |
| 1861 | noteProof(proof); |
| 1862 | resumeIntent(); |
| 1863 | } catch (e) { |
| 1864 | confirmStatus(friendlyCeremonyError(e, "signin").message, "error"); |
| 1865 | } finally { |
| 1866 | prefetchLogin(); // ceremony options are single-use — ready the next |
| 1867 | btn.disabled = false; |
| 1868 | } |
| 1869 | } |
| 1870 | |
| 1871 | function confirmMethodsApple(e) { |
| 1872 | // A fresh Apple sign-in mints the proof (finishSiwa pockets it); the |
| 1873 | // markers route the return leg straight back into the flow: methodsReturn |
| 1874 | // lands on the step family, and the serialized intent RESUMES the parked |
| 1875 | // change (the add step opens, a removal re-arms) instead of the bare list. |
| 1876 | const intent = intentToken(pendingIntent); |
| 1877 | pendingIntent = null; // payloads can't survive the navigation; the token rides |
| 1878 | startAppleSignIn(e, { methodsReturn: true, intent }); |
| 1879 | } |
| 1880 | |
| 1881 | // confirmMethodsOidc — the Apple confirm through an OIDC web door: the same |
| 1882 | // marker-routed round trip (finishOidc pockets the proof, methodsReturn |
| 1883 | // lands back on the step family, the intent resumes). select_account so a |
| 1884 | // person with several provider accounts can pick the linked one. |
| 1885 | function confirmMethodsOidc(provider, e) { |
| 1886 | const intent = intentToken(pendingIntent); |
| 1887 | pendingIntent = null; // payloads can't survive the navigation; the token rides |
| 1888 | startOidcSignIn(provider, e, { methodsReturn: true, intent, select: true }); |
| 1889 | } |
| 1890 | |
| 1891 | // The confirm step's email re-verify: the same two-step form as the door, |
| 1892 | // but the answer's TOKEN is deliberately dropped — the proof is the point, |
| 1893 | // and this tab keeps the session it already has. |
| 1894 | async function cemailSend(e) { |
| 1895 | e.preventDefault(); |
| 1896 | const email = $("methods-cemail-addr").value.trim(); |
| 1897 | const btn = $("methods-cemail-send"); |
| 1898 | btn.disabled = true; |
| 1899 | btn.classList.add("loading"); |
| 1900 | confirmStatus("Sending your code…", "busy"); |
| 1901 | const spinFloor = new Promise((r) => setTimeout(r, 900)); // same floor as the door |
| 1902 | try { |
| 1903 | await Promise.all([apiJSON("/api/email/request", { |
| 1904 | method: "POST", |
| 1905 | headers: { "Content-Type": "application/json" }, |
| 1906 | body: JSON.stringify({ email }), |
| 1907 | }), spinFloor]); |
| 1908 | confirmStatus("Check your email for a 6-digit code.", ""); |
| 1909 | $("methods-cemail-form").classList.add("hidden"); |
| 1910 | $("methods-cemail-code-form").classList.remove("hidden"); |
| 1911 | $("methods-cemail-code").focus(); |
| 1912 | } catch (err) { |
| 1913 | confirmStatus(err.message || "Couldn’t send the code — try again.", "error"); |
| 1914 | } finally { |
| 1915 | btn.disabled = false; |
| 1916 | btn.classList.remove("loading"); |
| 1917 | } |
| 1918 | } |
| 1919 | |
| 1920 | async function cemailVerify(e) { |
| 1921 | e.preventDefault(); |
| 1922 | const email = $("methods-cemail-addr").value.trim(); |
| 1923 | const code = $("methods-cemail-code").value.trim(); |
| 1924 | const btn = $("methods-cemail-verify"); |
| 1925 | btn.disabled = true; |
| 1926 | btn.classList.add("loading"); |
| 1927 | confirmStatus("Checking your code…", "busy"); |
| 1928 | try { |
| 1929 | const res = await apiJSON("/api/email/verify", { |
| 1930 | method: "POST", |
| 1931 | headers: { "Content-Type": "application/json" }, |
| 1932 | body: JSON.stringify({ email, code }), |
| 1933 | }); |
| 1934 | noteProof(res.proof); |
| 1935 | resumeIntent(); |
| 1936 | } catch (err) { |
| 1937 | confirmStatus(err.message || "That code didn’t work — try again.", "error"); |
| 1938 | } finally { |
| 1939 | btn.disabled = false; |
| 1940 | btn.classList.remove("loading"); |
| 1941 | } |
| 1942 | } |
| 1943 | |
| 1944 | // ---- the add steps -------------------------------------------------------------- |
| 1945 | |
| 1946 | // startAddApple begins the ADD-APPLE round trip: a fresh proof first — |
| 1947 | // serially, via the confirm step when needed — then the ordinary Apple |
| 1948 | // hand-off with the link marker, so the #siwa= return posts link:true |
| 1949 | // instead of signing in (the proof rides sessionStorage beside the state; |
| 1950 | // the server holds it to the same 5-minute window). |
| 1951 | function startAddApple(e) { |
| 1952 | const src = e && e.currentTarget; |
| 1953 | const go = (proof) => startAppleSignIn( |
| 1954 | // The pressed button spins when it's still on screen (the big first-add |
| 1955 | // button); a menu-born add has no visible button left, so a detached one |
| 1956 | // absorbs the spinner and the smoke cover carries the wait. |
| 1957 | { currentTarget: src && src.isConnected ? src : document.createElement("button") }, |
| 1958 | { linkProof: { proof, expires: ceremonyProof ? ceremonyProof.expires : Date.now() + PROOF_TTL_MS } }); |
| 1959 | const p = freshProof(); |
| 1960 | if (p) { go(p); return; } |
| 1961 | pendingIntent = { kind: "add-apple" }; |
| 1962 | openMethodsConfirmStep(); |
| 1963 | } |
| 1964 | |
| 1965 | // startAddOidc — the ADD round trip through any OIDC web door. |
| 1966 | function startAddOidc(provider, e) { |
| 1967 | const src = e && e.currentTarget; |
| 1968 | const go = (proof) => startOidcSignIn(provider, |
| 1969 | { currentTarget: src && src.isConnected ? src : document.createElement("button") }, |
| 1970 | { select: true, linkProof: { proof, expires: ceremonyProof ? ceremonyProof.expires : Date.now() + PROOF_TTL_MS } }); |
| 1971 | const p = freshProof(); |
| 1972 | if (p) { go(p); return; } |
| 1973 | pendingIntent = { kind: "add-" + provider }; |
| 1974 | openMethodsConfirmStep(); |
| 1975 | } |
| 1976 | function startAddGoogle(e) { startAddOidc("google", e); } |
| 1977 | |
| 1978 | // startAddEmail / startAddPasskey open their serial card steps — confirm |
| 1979 | // first when the pocket is empty (list → confirm → the add step → list). |
| 1980 | function startAddEmail() { |
| 1981 | if (freshProof()) { openMethodsEmailStep(); return; } |
| 1982 | pendingIntent = { kind: "add-email" }; |
| 1983 | openMethodsConfirmStep(); |
| 1984 | } |
| 1985 | function startAddPasskey() { |
| 1986 | if (freshProof()) { openMethodsPasskeyStep(); return; } |
| 1987 | pendingIntent = { kind: "add-passkey" }; |
| 1988 | openMethodsConfirmStep(); |
| 1989 | } |
| 1990 | |
| 1991 | function openMethodsEmailStep() { |
| 1992 | resetMethodsSteps(); |
| 1993 | showMethodsScreen("methods-email"); |
| 1994 | $("methods-email-addr").focus(); |
| 1995 | } |
| 1996 | |
| 1997 | async function methodsEmailSend(e) { |
| 1998 | e.preventDefault(); |
| 1999 | const email = $("methods-email-addr").value.trim(); |
| 2000 | const btn = $("methods-email-send"); |
| 2001 | btn.disabled = true; |
| 2002 | btn.classList.add("loading"); |
| 2003 | emailStepStatus("Sending your code…", "busy"); |
| 2004 | const spinFloor = new Promise((r) => setTimeout(r, 900)); |
| 2005 | try { |
| 2006 | await Promise.all([apiJSON("/api/email/request", { |
| 2007 | method: "POST", |
| 2008 | headers: { "Content-Type": "application/json" }, |
| 2009 | body: JSON.stringify({ email }), |
| 2010 | }), spinFloor]); |
| 2011 | emailStepStatus("Check your email for a 6-digit code.", ""); |
| 2012 | $("methods-email-form").classList.add("hidden"); |
| 2013 | $("methods-email-code-form").classList.remove("hidden"); |
| 2014 | $("methods-email-code").focus(); |
| 2015 | } catch (err) { |
| 2016 | emailStepStatus(err.message || "Couldn’t send the code — try again.", "error"); |
| 2017 | } finally { |
| 2018 | btn.disabled = false; |
| 2019 | btn.classList.remove("loading"); |
| 2020 | } |
| 2021 | } |
| 2022 | |
| 2023 | async function methodsEmailVerify(e) { |
| 2024 | e.preventDefault(); |
| 2025 | const email = $("methods-email-addr").value.trim(); |
| 2026 | const code = $("methods-email-code").value.trim(); |
| 2027 | const proof = freshProof(); |
| 2028 | if (!proof) { |
| 2029 | // The proof lapsed while the code round-tripped. The code survives — |
| 2030 | // link mode gates on the proof BEFORE redeeming (email.go) — so run the |
| 2031 | // confirm step again, then retry this exact verify. |
| 2032 | pendingIntent = { kind: "verify-email", email, code }; |
| 2033 | openMethodsConfirmStep("That took a little while — confirm it’s you again."); |
| 2034 | return; |
| 2035 | } |
| 2036 | const btn = $("methods-email-verify"); |
| 2037 | btn.disabled = true; |
| 2038 | btn.classList.add("loading"); |
| 2039 | emailStepStatus("Checking your code…", "busy"); |
| 2040 | try { |
| 2041 | await finishEmailAdd(email, code, proof); |
| 2042 | } finally { |
| 2043 | btn.disabled = false; |
| 2044 | btn.classList.remove("loading"); |
| 2045 | } |
| 2046 | } |
| 2047 | |
| 2048 | // finishEmailAdd posts the link verify and lands the outcome: success goes |
| 2049 | // back to the list with the good news; a refusal shows wherever the person |
| 2050 | // is (the add step, or the list after a confirm-step retry), verbatim. The |
| 2051 | // 409 ("that address already signs in somewhere else") is the COMBINE |
| 2052 | // doorway (#1098): the error line grows the offer that opens the combine |
| 2053 | // step — keyed off the status, never off message text. |
| 2054 | async function finishEmailAdd(email, code, proof) { |
| 2055 | const res = await fetch("/api/email/verify", { |
| 2056 | method: "POST", |
| 2057 | headers: { "Content-Type": "application/json", "X-Ceremony-Proof": proof }, |
| 2058 | body: JSON.stringify({ email, code, link: true }), |
| 2059 | }); |
| 2060 | if (res.ok) { |
| 2061 | labelNewEmail(email); // fire-and-forget: the address becomes the row's E2EE label |
| 2062 | backToMethodsList("Email added — you can sign in with it now.", "ok"); |
| 2063 | return; |
| 2064 | } |
| 2065 | const msg = ((await res.text().catch(() => "")) || "").trim() || "That code didn’t work — try again."; |
| 2066 | const onEmailStep = !$("methods-email").classList.contains("hidden"); |
| 2067 | if (onEmailStep) emailStepStatus(msg, "error"); else backToMethodsList(msg, "error"); |
| 2068 | if (res.status === 409) { |
| 2069 | combine = { kind: "email", email }; |
| 2070 | showCombineOffer(onEmailStep ? "methods-email-status" : "methods-status"); |
| 2071 | } |
| 2072 | } |
| 2073 | |
| 2074 | // The add-passkey step: one button, so navigator.credentials.create fires |
| 2075 | // inside a fresh tap (Safari drops the activation across a network await). |
| 2076 | // The register options are fetched WITH the proof at step open — that's what |
| 2077 | // makes the new passkey attach to THIS account instead of minting a fresh |
| 2078 | // one (webauthn.go's register/begin). |
| 2079 | let passkeyAddBegin = null; |
| 2080 | |
| 2081 | function openMethodsPasskeyStep() { |
| 2082 | resetMethodsSteps(); |
| 2083 | showMethodsScreen("methods-passkey"); |
| 2084 | $("methods-passkey-create").focus(); |
| 2085 | prefetchPasskeyAddBegin(); |
| 2086 | } |
| 2087 | |
| 2088 | function prefetchPasskeyAddBegin() { |
| 2089 | const proof = freshProof(); |
| 2090 | if (!proof) return; |
| 2091 | apiJSON("/api/register/begin", { method: "POST", headers: { "X-Ceremony-Proof": proof } }) |
| 2092 | .then((v) => { passkeyAddBegin = v; }) |
| 2093 | .catch(() => { passkeyAddBegin = null; }); |
| 2094 | } |
| 2095 | |
| 2096 | async function addPasskeyCeremony() { |
| 2097 | const btn = $("methods-passkey-create"); |
| 2098 | btn.disabled = true; |
| 2099 | passkeyStepStatus("Creating your passkey…", "busy"); |
| 2100 | let begin = passkeyAddBegin; |
| 2101 | passkeyAddBegin = null; // ceremony options are single-use |
| 2102 | try { |
| 2103 | if (!begin) { |
| 2104 | const proof = freshProof(); |
| 2105 | if (!proof) { |
| 2106 | // Sat on this step past the window — the confirm step runs again, |
| 2107 | // then reopens here with fresh proof-carrying options. |
| 2108 | pendingIntent = { kind: "add-passkey" }; |
| 2109 | openMethodsConfirmStep("That took a little while — confirm it’s you again."); |
| 2110 | return; |
| 2111 | } |
| 2112 | // The prefetch missed (slow network); fetching here may cost Safari's |
| 2113 | // activation — the retry tap then uses the fresh prefetch below. |
| 2114 | begin = await apiJSON("/api/register/begin", { method: "POST", headers: { "X-Ceremony-Proof": proof } }); |
| 2115 | } |
| 2116 | const reg = await runRegister(begin); |
| 2117 | noteProof(reg.proof); |
| 2118 | // When this tab holds the DEK and a blob already exists, wrap it for the |
| 2119 | // new credential too — the passkey then OPENS the list, not just signs |
| 2120 | // in. Sealed/anchor tabs (and blob-less accounts — an empty vault save |
| 2121 | // would trip the creation-eligibility gate, see createPasskey) skip it: |
| 2122 | // identity-only is still a real way in under the anchor model. |
| 2123 | if (dekKey && vault && vault.version !== "0" && reg.prf) { |
| 2124 | try { |
| 2125 | const kek = await kekFromPRF(reg.prf); |
| 2126 | vault.keys[reg.credId] = await wrapDEK(dekKey, kek); |
| 2127 | await saveVault(); |
| 2128 | } catch { /* the passkey works for sign-in; the wrap can land later */ } |
| 2129 | } |
| 2130 | backToMethodsList("Passkey added — one tap next time on this device.", "ok"); |
| 2131 | } catch (e) { |
| 2132 | prefetchPasskeyAddBegin(); // options were spent — ready a retry |
| 2133 | if (e && e.message === "no-prf") { |
| 2134 | passkeyStepStatus("This device can’t protect your sign-in here (no PRF support) — try a different device or password manager.", "error"); |
| 2135 | } else { |
| 2136 | passkeyStepStatus(formatCeremonyError(e, "create"), "error"); |
| 2137 | } |
| 2138 | } finally { |
| 2139 | btn.disabled = false; |
| 2140 | } |
| 2141 | } |
| 2142 | |
| 2143 | // ---- combine your accounts (#1098's merge) -------------------------------------- |
| 2144 | |
| 2145 | // The in-flight combine context, memory-only: which anchor kind collided |
| 2146 | // ("email" | "apple"), the typed address (email kind), and — once stage A |
| 2147 | // proves the other side — the ABSORBED account's credentials. Those are |
| 2148 | // deliberately never adopted as the session and never persisted: this tab |
| 2149 | // stays signed into the SURVIVOR; the absorbed token+proof exist only to |
| 2150 | // move its vault and feed absorb_proof to POST /api/account/merge |
| 2151 | // (merge.go). Cancel anywhere just drops this object — nothing destructive |
| 2152 | // happens before the explicit beats. |
| 2153 | let combine = null; // { kind, email?, absorbedToken?, absorbedProof?, absorbedExpires?, absorbedBlob?, absorbedPasskeyIds? } |
| 2154 | |
| 2155 | function combineStatus(msg, kind) { |
| 2156 | const el = $("combine-status"); |
| 2157 | el.textContent = msg || ""; |
| 2158 | el.className = "status" + (kind ? " " + kind : ""); |
| 2159 | } |
| 2160 | |
| 2161 | // showCombineOffer grows the offer under whichever status line carries the |
| 2162 | // 409 — the node moves beside it (like #newhere follows its surface) and |
| 2163 | // resets hide it again. |
| 2164 | function showCombineOffer(statusId) { |
| 2165 | $(statusId).insertAdjacentElement("afterend", $("combine-offer")); |
| 2166 | $("combine-offer").classList.remove("hidden"); |
| 2167 | } |
| 2168 | |
| 2169 | // absorbedProofFresh mirrors freshProof for the OTHER side's proof — the |
| 2170 | // merge and the absorbed-blob delete both refuse a stale one server-side, |
| 2171 | // so a lapsed proof re-runs the prove stage instead of burning a call. |
| 2172 | function absorbedProofFresh() { |
| 2173 | return !!(combine && combine.absorbedProof && combine.absorbedExpires - PROOF_MARGIN_MS > Date.now()); |
| 2174 | } |
| 2175 | |
| 2176 | // openCombineStep is the serial combine screen. Stage A (prove the other |
| 2177 | // account) when the absorbed side is still unproven; a proven side goes |
| 2178 | // straight to stage B (its spaces). The Apple prove is a full round trip |
| 2179 | // (merge-apple intent), so its return re-enters here already proven. |
| 2180 | function openCombineStep() { |
| 2181 | if (!combine) { backToMethodsList("", "", { reload: false }); return; } |
| 2182 | resetMethodsSteps(); |
| 2183 | showMethodsScreen("methods-combine"); |
| 2184 | $("combine-title").focus(); |
| 2185 | if (absorbedProofFresh()) { combineVaultStage(); return; } |
| 2186 | combine.absorbedProof = ""; // a stale proof re-proves from scratch |
| 2187 | $("combine-prove").classList.remove("hidden"); |
| 2188 | if (combine.kind === "email") { |
| 2189 | // The add-flow's code was spent by the 409 (the redeem happens before |
| 2190 | // the conflict answer), so a FRESH code proves the other side — say so. |
| 2191 | $("combine-prove-note").textContent = |
| 2192 | "First, prove the other account is yours: we’re sending another code to " + combine.email + "."; |
| 2193 | $("combine-code").classList.remove("hidden"); |
| 2194 | combineRequestCode(); |
| 2195 | $("combine-code-input").focus(); |
| 2196 | } else if (combine.kind === "apple") { |
| 2197 | // The button says it all (Paul: no "First, prove…" preamble); only the |
| 2198 | // email prove keeps its note — it names where the code went. |
| 2199 | $("combine-prove-note").textContent = ""; |
| 2200 | $("combine-apple-go").classList.remove("hidden"); |
| 2201 | $("combine-apple-go").focus(); |
| 2202 | } else { |
| 2203 | // A provider kind (google/microsoft/github): one generic go button, |
| 2204 | // dressed per provider — brand class, display name, cloned glyph. |
| 2205 | const p = OIDC_WEB[combine.kind]; |
| 2206 | $("combine-prove-note").textContent = ""; |
| 2207 | const go = $("combine-oidc-go"); |
| 2208 | go.className = { google: "google-btn", microsoft: "ms-btn", github: "gh-btn" }[combine.kind]; |
| 2209 | go.textContent = "Continue with " + p.display; |
| 2210 | const glyph = kindGlyph(combine.kind); |
| 2211 | if (glyph) go.prepend(glyph); |
| 2212 | go.focus(); |
| 2213 | } |
| 2214 | } |
| 2215 | |
| 2216 | async function combineRequestCode() { |
| 2217 | combineStatus("Sending a code…", "busy"); |
| 2218 | try { |
| 2219 | await apiJSON("/api/email/request", { |
| 2220 | method: "POST", |
| 2221 | headers: { "Content-Type": "application/json" }, |
| 2222 | body: JSON.stringify({ email: combine.email }), |
| 2223 | }); |
| 2224 | combineStatus("We sent another code to prove that account is yours — check your email.", ""); |
| 2225 | } catch (e) { |
| 2226 | combineStatus((e && e.message) || "Couldn’t send the code — try again.", "error"); |
| 2227 | } |
| 2228 | } |
| 2229 | |
| 2230 | // The prove-by-code verify runs in SIGN-IN mode (link:false): the answer is |
| 2231 | // the absorbed account's token + proof, held in memory only (see combine). |
| 2232 | async function combineCodeVerify(e) { |
| 2233 | e.preventDefault(); |
| 2234 | const code = $("combine-code-input").value.trim(); |
| 2235 | const btn = $("combine-code-verify"); |
| 2236 | btn.disabled = true; |
| 2237 | btn.classList.add("loading"); |
| 2238 | combineStatus("Checking your code…", "busy"); |
| 2239 | const spinFloor = new Promise((r) => setTimeout(r, 900)); // same floor as the door |
| 2240 | try { |
| 2241 | const [res] = await Promise.all([apiJSON("/api/email/verify", { |
| 2242 | method: "POST", |
| 2243 | headers: { "Content-Type": "application/json" }, |
| 2244 | body: JSON.stringify({ email: combine.email, code }), |
| 2245 | }), spinFloor]); |
| 2246 | combine.absorbedToken = res.token; |
| 2247 | combine.absorbedProof = res.proof; |
| 2248 | combine.absorbedExpires = Date.now() + PROOF_TTL_MS; |
| 2249 | combineStatus(""); |
| 2250 | combineVaultStage(); |
| 2251 | } catch (err) { |
| 2252 | combineStatus((err && err.message) || "That code didn’t work — try again.", "error"); |
| 2253 | } finally { |
| 2254 | btn.disabled = false; |
| 2255 | btn.classList.remove("loading"); |
| 2256 | } |
| 2257 | } |
| 2258 | |
| 2259 | // The prove-by-Apple leg: a full round trip carrying the merge-apple intent; |
| 2260 | // finishSiwaMergeProve picks the return up and re-enters the step proven. |
| 2261 | function combineAppleProve(e) { |
| 2262 | startAppleSignIn(e, { intent: "merge-apple" }); |
| 2263 | } |
| 2264 | |
| 2265 | // kindGlyph clones a kind's brand mark from its all-ways button — one |
| 2266 | // source per glyph (static, in-code SVG), sized for inline button use. |
| 2267 | function kindGlyph(kind) { |
| 2268 | const src = $("otherways-" + (kind === "siwa" ? "apple" : kind)); |
| 2269 | const svg = src && src.querySelector("svg"); |
| 2270 | if (!svg) return null; |
| 2271 | const c = svg.cloneNode(true); |
| 2272 | c.classList.remove("pk-ico"); |
| 2273 | c.classList.add("btn-ico"); |
| 2274 | return c; |
| 2275 | } |
| 2276 | |
| 2277 | // Stage B: the absorbed account's saved spaces. Nothing to move (no blob) |
| 2278 | // skips straight to the merge; a real blob offers the honest choices — |
| 2279 | // unlock with ITS passkey and bring them over, or (behind an inline |
| 2280 | // are-you-sure) combine without them, destroying its sealed list. |
| 2281 | async function combineVaultStage() { |
| 2282 | $("combine-prove").classList.add("hidden"); |
| 2283 | $("combine-code").classList.add("hidden"); |
| 2284 | $("combine-apple-go").classList.add("hidden"); |
| 2285 | $("combine-oidc-go").classList.add("hidden"); |
| 2286 | $("combine-skip-confirm").classList.add("hidden"); |
| 2287 | combineStatus("Checking the other account’s spaces…", "busy"); |
| 2288 | let got; |
| 2289 | try { |
| 2290 | got = await fetchBlob(combine.absorbedToken); |
| 2291 | if (got) { |
| 2292 | const r = await apiJSON("/api/anchors", { headers: { Authorization: "Bearer " + combine.absorbedToken } }); |
| 2293 | combine.absorbedPasskeyIds = (r.anchors || []).filter((x) => x.kind === "passkey").map((x) => x.id); |
| 2294 | } |
| 2295 | } catch (e) { |
| 2296 | combineStatus((e && e.message) || "Couldn’t reach the other account — try again.", "error"); |
| 2297 | return; |
| 2298 | } |
| 2299 | if (!got) { combineFinish(0); return; } // (a) nothing to move |
| 2300 | combine.absorbedBlob = got; |
| 2301 | combineStatus(""); |
| 2302 | const canCeremony = !!window.PublicKeyCredential && combine.absorbedPasskeyIds.length > 0; |
| 2303 | // The union needs the SURVIVOR side open too: its DEK in hand and at least |
| 2304 | // one wrapped copy of it to seal under (a keys-less PUT would mint a blob |
| 2305 | // nobody could ever open). |
| 2306 | const canUnion = !!dekKey && !!vault && Object.keys(vault.keys || {}).length > 0; |
| 2307 | $("combine-vault").classList.remove("hidden"); |
| 2308 | $("combine-unlock").classList.toggle("hidden", !(canCeremony && canUnion)); |
| 2309 | $("combine-skip").classList.remove("hidden"); |
| 2310 | const note = $("combine-vault-note"); |
| 2311 | if (canCeremony && canUnion) { |
| 2312 | note.textContent = "The other account has saved spaces. Unlock them with that account’s passkey and they’ll move over with everything else."; |
| 2313 | $("combine-unlock").focus(); |
| 2314 | prefetchLogin(); // the unlock ceremony wants gesture-fresh options |
| 2315 | } else if (!canUnion) { |
| 2316 | note.textContent = "The other account has saved spaces, but your own spaces are locked right now, so they can’t be brought over. Go back and unlock your spaces with your passkey first — or combine without its spaces."; |
| 2317 | $("combine-skip").focus(); |
| 2318 | } else { |
| 2319 | note.textContent = "The other account has saved spaces, but it has no passkey to unlock them, so they can’t be brought over."; |
| 2320 | $("combine-skip").focus(); |
| 2321 | } |
| 2322 | } |
| 2323 | |
| 2324 | // combineUnlock runs the ABSORBED account's passkey ceremony, decrypts its |
| 2325 | // vault, unions it into the survivor's (survivor wins ties, tombstones from |
| 2326 | // either side hold, exactly saveVault's race rules), re-encrypts under the |
| 2327 | // survivor's DEK, deletes the absorbed blob, then merges. |
| 2328 | async function combineUnlock() { |
| 2329 | const btn = $("combine-unlock"); |
| 2330 | btn.disabled = true; |
| 2331 | combineStatus("Confirm with the OTHER account’s passkey…", "busy"); |
| 2332 | let fin; |
| 2333 | try { |
| 2334 | fin = await runLogin(loginBegin); |
| 2335 | } catch (e) { |
| 2336 | combineStatus(friendlyCeremonyError(e, "signin").message, "error"); |
| 2337 | prefetchLogin(); |
| 2338 | btn.disabled = false; |
| 2339 | return; |
| 2340 | } |
| 2341 | prefetchLogin(); // options were spent either way |
| 2342 | // The ceremony MUST land on the absorbed account — its credential ids are |
| 2343 | // exactly the passkey ids its anchors listing gave us. A miss means the |
| 2344 | // wrong passkey was picked (likely this account's own): explain and retry. |
| 2345 | if (!combine.absorbedPasskeyIds.includes(fin.credId)) { |
| 2346 | combineStatus("That passkey opens a different account — choose the OTHER account’s passkey and try again.", "error"); |
| 2347 | btn.disabled = false; |
| 2348 | return; |
| 2349 | } |
| 2350 | // Freshest absorbed credentials win (the ceremony just minted a proof). |
| 2351 | combine.absorbedToken = fin.token; |
| 2352 | combine.absorbedProof = fin.proof; |
| 2353 | combine.absorbedExpires = Date.now() + PROOF_TTL_MS; |
| 2354 | try { |
| 2355 | if (!fin.prf) throw new Error("That passkey didn’t return the key material needed to unlock (no PRF) — try another device, or combine without its spaces."); |
| 2356 | const kek = await kekFromPRF(fin.prf); |
| 2357 | const wrapped = unpackBlob(combine.absorbedBlob.bytes).keys[fin.credId]; |
| 2358 | if (!wrapped) throw new Error("That passkey can’t unlock the other account’s spaces."); |
| 2359 | const absDek = await unwrapDEK(wrapped, kek); |
| 2360 | const opened = await openBlob(combine.absorbedBlob.bytes, absDek); |
| 2361 | combineStatus("Bringing its spaces over…", "busy"); |
| 2362 | // Union under saveVault's rules: absorbed first, survivor overlays |
| 2363 | // (survivor wins ties by url); tombstones from either side hold unless |
| 2364 | // the SURVIVOR's live list still carries the entry. |
| 2365 | const before = (vault.obj.servers || []).length; |
| 2366 | const merged = new Map((opened.obj.servers || []).map((sv) => [sv.url, sv])); |
| 2367 | for (const sv of vault.obj.servers || []) merged.set(sv.url, sv); |
| 2368 | const removed = mergeRemoved(opened.obj.removed, vault.obj.removed) |
| 2369 | .filter((t) => !(vault.obj.servers || []).some((sv) => sv.url === t.url)); |
| 2370 | vault.obj.servers = [...merged.values()].filter((sv) => !removed.some((t) => t.url === sv.url)); |
| 2371 | if (removed.length) vault.obj.removed = removed; else delete vault.obj.removed; |
| 2372 | // Method labels ride the union too: absorbed first, survivor overlays — |
| 2373 | // the moved-over anchors keep the names their old account gave them. |
| 2374 | const labels = { ...(opened.obj.methods || {}), ...(vault.obj.methods || {}) }; |
| 2375 | if (Object.keys(labels).length) vault.obj.methods = labels; |
| 2376 | const movedSpaces = vault.obj.servers.length - before; |
| 2377 | // The absorbed passkey just proved AND unlocked — after the merge it |
| 2378 | // signs into the survivor, so wrap the survivor's DEK for it too: it |
| 2379 | // should OPEN the combined list, not hit the sealed prompt. |
| 2380 | vault.keys[fin.credId] = await wrapDEK(dekKey, kek); |
| 2381 | await saveVault(); // CAS PUT of the survivor's union, retrying once |
| 2382 | await deleteAbsorbedBlob(); // client-first ordering: emptied before merge |
| 2383 | combineFinish(movedSpaces); |
| 2384 | } catch (e) { |
| 2385 | combineStatus((e && e.message) || "Couldn’t bring its spaces over.", "error"); |
| 2386 | btn.disabled = false; |
| 2387 | } |
| 2388 | } |
| 2389 | |
| 2390 | // The skip path's inline are-you-sure: destroying the absorbed account's |
| 2391 | // sealed spaces list must be an explicit, plainly-worded beat of its own. |
| 2392 | function combineSkip() { |
| 2393 | $("combine-unlock").classList.add("hidden"); |
| 2394 | $("combine-skip").classList.add("hidden"); |
| 2395 | $("combine-skip-confirm").classList.remove("hidden"); |
| 2396 | $("combine-skip-yes").focus(); |
| 2397 | } |
| 2398 | |
| 2399 | async function combineSkipYes() { |
| 2400 | const btn = $("combine-skip-yes"); |
| 2401 | btn.disabled = true; |
| 2402 | combineStatus("Clearing its spaces list…", "busy"); |
| 2403 | try { |
| 2404 | await deleteAbsorbedBlob(); |
| 2405 | combineFinish(0); |
| 2406 | } catch (e) { |
| 2407 | combineStatus((e && e.message) || "Couldn’t clear the other account’s spaces list.", "error"); |
| 2408 | } finally { |
| 2409 | btn.disabled = false; |
| 2410 | } |
| 2411 | } |
| 2412 | |
| 2413 | // deleteAbsorbedBlob is the ceremony-gated DELETE /api/blob, authed by the |
| 2414 | // ABSORBED side's proof — merge.go's precondition (client-first ordering). |
| 2415 | async function deleteAbsorbedBlob() { |
| 2416 | if (!absorbedProofFresh()) { |
| 2417 | openCombineStep(); // stale other-side proof: prove again, then retry |
| 2418 | combineStatus("That took a while — prove the other account again first.", "error"); |
| 2419 | throw new Error("prove the other account again first"); |
| 2420 | } |
| 2421 | const res = await fetch("/api/blob", { method: "DELETE", headers: { "X-Ceremony-Proof": combine.absorbedProof } }); |
| 2422 | if (!res.ok && res.status !== 404) { |
| 2423 | throw new Error(((await res.text().catch(() => "")) || "").trim() || "Couldn’t clear the other account’s spaces list."); |
| 2424 | } |
| 2425 | } |
| 2426 | |
| 2427 | // Stage C: the merge itself — both sides' fresh proofs (merge.go's |
| 2428 | // both-sides-proofs auth), then the plain-words outcome back on the list. |
| 2429 | async function combineFinish(movedSpaces = 0) { |
| 2430 | if (!combine) { backToMethodsList("", "", { reload: false }); return; } |
| 2431 | const sp = freshProof(); |
| 2432 | if (!sp) { |
| 2433 | // The SURVIVOR's proof lapsed mid-combine: the confirm step runs, then |
| 2434 | // finishes right here (memory-only intent — a confirm-with-Apple trip |
| 2435 | // would lose the combine context, and intentToken serializes this to |
| 2436 | // nothing on purpose). |
| 2437 | pendingIntent = { kind: "combine-finish", movedSpaces }; |
| 2438 | openMethodsConfirmStep("That took a while — confirm it’s you again to finish combining."); |
| 2439 | return; |
| 2440 | } |
| 2441 | if (!absorbedProofFresh()) { |
| 2442 | openCombineStep(); |
| 2443 | combineStatus("That took a while — prove the other account again first.", "error"); |
| 2444 | return; |
| 2445 | } |
| 2446 | showMethodsScreen("methods-combine"); |
| 2447 | combineStatus("Combining the accounts…", "busy"); |
| 2448 | let res; |
| 2449 | try { |
| 2450 | res = await fetch("/api/account/merge", { |
| 2451 | method: "POST", |
| 2452 | headers: { |
| 2453 | Authorization: "Bearer " + state.token, |
| 2454 | "X-Ceremony-Proof": sp, |
| 2455 | "Content-Type": "application/json", |
| 2456 | }, |
| 2457 | body: JSON.stringify({ absorb_proof: combine.absorbedProof }), |
| 2458 | }); |
| 2459 | } catch { |
| 2460 | combineStatus("Couldn’t reach the server — try again.", "error"); |
| 2461 | return; |
| 2462 | } |
| 2463 | if (!res.ok) { |
| 2464 | // The server's plain refusals verbatim (same-account, still-holds-a- |
| 2465 | // backup, a stale proof), landed on this step for another go. |
| 2466 | combineStatus(((await res.text().catch(() => "")) || "").trim() || "Couldn’t combine the accounts.", "error"); |
| 2467 | return; |
| 2468 | } |
| 2469 | const body = await res.json().catch(() => ({})); |
| 2470 | const movedMethods = (body.moved && (body.moved.anchors || 0) + (body.moved.passkeys || 0)) || 0; |
| 2471 | let msg = "Accounts combined — " + movedMethods.toLocaleString() + " sign-in method" + |
| 2472 | (movedMethods === 1 ? "" : "s") + " moved"; |
| 2473 | if (movedSpaces > 0) { |
| 2474 | msg += ", " + movedSpaces.toLocaleString() + " space" + (movedSpaces === 1 ? "" : "s") + " brought over"; |
| 2475 | } |
| 2476 | msg += "."; |
| 2477 | combine = null; |
| 2478 | backToMethodsList(msg, "ok"); // reloads the anchors — the moved methods appear |
| 2479 | if (movedSpaces > 0 && vault) provisioned = null; // the ledger may know the new spaces |
| 2480 | } |
| 2481 | |
| 2482 | // ---- spaces list -------------------------------------------------------------- |
| 2483 | |
| 2484 | // hideVault parks the spaces screen for a sibling screen (or sign-out). The |
| 2485 | // siwa-arrival class comes off HERE, under display:none where it's free — |
| 2486 | // removing it while the vault is visible changes the computed animation-name |
| 2487 | // back to .screen's, and a changed name RESTARTS the entry animation on an |
| 2488 | // already-visible card (Paul's after-flash). An open account menu doesn't |
| 2489 | // survive the screen either. |
| 2490 | function hideVault() { |
| 2491 | closeAccountMenu(false); |
| 2492 | $("vault").classList.add("hidden"); |
| 2493 | $("vault").classList.remove("arrive"); |
| 2494 | } |
| 2495 | |
| 2496 | // Creating a space is for people already IN one: the + hides until the |
| 2497 | // decrypted vault actually holds a space entry (an empty account's + only |
| 2498 | // invited the server's 403 dead-end — creation is gated to members, and the |
| 2499 | // sealed and empty states both hide it). The server enforces the same |
| 2500 | // boundary on POST /api/spaces. |
| 2501 | function canCreateSpace() { |
| 2502 | return spacesEnabled && !!(vault && (vault.obj.servers || []).length); |
| 2503 | } |
| 2504 | |
| 2505 | function showVault() { |
| 2506 | if (consumeJoinReturn()) return; // pre-join sign-in: back to the join page, not the list |
| 2507 | $("signed-out").classList.add("hidden"); |
| 2508 | $("reassure").classList.add("hidden"); |
| 2509 | resetEmailDoor(); // arriving from the email door hides it |
| 2510 | resetOtherWays(); // …and from the all-ways step likewise |
| 2511 | $("create").classList.add("hidden"); |
| 2512 | $("archive").classList.add("hidden"); |
| 2513 | $("vault-sealed").classList.add("hidden"); |
| 2514 | $("vault").classList.remove("hidden"); |
| 2515 | $("open-create").classList.toggle("hidden", !canCreateSpace()); // the + to create |
| 2516 | |
| 2517 | const box = $("spaces"); |
| 2518 | box.textContent = ""; |
| 2519 | |
| 2520 | // Archived spaces are pulled out first — hidden from the main list, reachable |
| 2521 | // through the "Archived (N)" affordance at the foot. |
| 2522 | const { active, archived } = partitionArchived(vault.obj.servers || []); |
| 2523 | |
| 2524 | // A just-archived space leaves an inline "Archived · Undo" banner (never a |
| 2525 | // floating toast); it survives re-renders and auto-clears on a timer. |
| 2526 | if (pendingUndo) box.append(undoBanner()); |
| 2527 | |
| 2528 | // Fuzzy search filters the active lists. It only appears once there's enough |
| 2529 | // to sift through; while a query is active, reordering is off (dragging a |
| 2530 | // filtered subset would be ambiguous) so the grips are hidden. |
| 2531 | const search = $("space-search"); |
| 2532 | search.classList.toggle("hidden", active.length < 4); |
| 2533 | const query = search.classList.contains("hidden") ? "" : search.value.trim(); |
| 2534 | |
| 2535 | // The invite-only card belongs only to the truly-empty state — someone |
| 2536 | // with archived spaces already has Eleven. |
| 2537 | $("invite-note").classList.toggle("hidden", !!(active.length || archived.length)); |
| 2538 | if (!active.length) { |
| 2539 | box.append(emptyLine(archived.length |
| 2540 | ? "All your spaces are archived — open the archive to bring one back." |
| 2541 | : "None (yet). Creating is invite-only for now.")); |
| 2542 | // Truly empty + signed in through an anchor: this may be the wrong-door |
| 2543 | // landing — their spaces could live under an older passkey account. |
| 2544 | const m = sessionMethod(); |
| 2545 | if (!archived.length && m && m !== "passkey" && window.PublicKeyCredential) |
| 2546 | box.append(bringOverLine()); |
| 2547 | } else { |
| 2548 | const servers = query ? filterServers(active, query) : active; |
| 2549 | if (!servers.length) { |
| 2550 | box.append(emptyLine("No spaces match “" + query + "”.")); |
| 2551 | } else { |
| 2552 | const sortable = !query; |
| 2553 | // Two independently-sorted lists: Starred (→ the app's rail) and the rest |
| 2554 | // (→ a "more" affordance). Headings appear only once something's starred. |
| 2555 | const { starred, others } = partitionServers(servers); |
| 2556 | if (starred.length) { |
| 2557 | box.append(sectionHeading("Starred"), serverList(starred, sortable)); |
| 2558 | if (others.length) box.append(sectionHeading("More")); |
| 2559 | } |
| 2560 | if (others.length) box.append(serverList(others, sortable)); |
| 2561 | } |
| 2562 | } |
| 2563 | |
| 2564 | if (archived.length) box.append(archivedButton(archived.length)); |
| 2565 | |
| 2566 | // The ledger read behind the Upgrade affordance — lazy, once per sign-in. |
| 2567 | if (spacesEnabled && provisioned === null && state.token) refreshProvisioned(); |
| 2568 | |
| 2569 | // Back from a pay-first checkout — or a marker parked by a tab that closed |
| 2570 | // on Stripe's page: claim the space into existence. Once per session id per |
| 2571 | // page life (the error pane's Try again drives retries); the claim itself |
| 2572 | // is idempotent downstream, so a duplicate is harmless. |
| 2573 | { |
| 2574 | const marker = pendingCreateMarker(); |
| 2575 | const session = paidCreateSession || (marker && marker.session) || ""; |
| 2576 | const region = paidCreateRegion || (marker && marker.region) || ""; |
| 2577 | paidCreateSession = ""; |
| 2578 | paidCreateRegion = ""; |
| 2579 | if (state.token && session && !claimedSessions.has(session)) { |
| 2580 | claimedSessions.add(session); |
| 2581 | claimPaidCreate(session, region); |
| 2582 | return; // the claim owns the screen now |
| 2583 | } |
| 2584 | } |
| 2585 | |
| 2586 | // Back from a completed checkout: thank inline, then re-read the ledger a few |
| 2587 | // seconds later — the webhook that flips the badge can lag the redirect. |
| 2588 | if (upgradedHost) { |
| 2589 | vaultStatus("Payment received — " + upgradedHost + " is upgraded. Thank you!", "ok"); |
| 2590 | upgradedHost = ""; |
| 2591 | setTimeout(() => { provisioned = null; if (vault && !$("vault").classList.contains("hidden")) showVault(); }, 5000); |
| 2592 | } |
| 2593 | |
| 2594 | // A pending #claim=<host> deep link resumes here, the first vault render |
| 2595 | // after sign-in — it needs only the account, not the provisioner ledger. |
| 2596 | consumeClaimLink(); |
| 2597 | } |
| 2598 | |
| 2599 | // refreshProvisioned learns which spaces this account created and their |
| 2600 | // entitlement (the provisioner's ledger, via home). No answer just means no |
| 2601 | // Upgrade affordance — the list renders fine without it. |
| 2602 | function refreshProvisioned() { |
| 2603 | provisioned = {}; // in flight — re-renders don't refetch |
| 2604 | apiJSON("/api/spaces", { headers: { Authorization: "Bearer " + state.token } }) |
| 2605 | .then((r) => { |
| 2606 | billingTiers = (r.billing && r.billing.tiers) || []; |
| 2607 | billingEnabled = billingTiers.length > 0; |
| 2608 | provisioned = {}; |
| 2609 | for (const sp of r.spaces || []) provisioned[sp.host] = sp; |
| 2610 | if (vault && !$("vault").classList.contains("hidden")) showVault(); |
| 2611 | // The create plan step renders its picker from this book — fill it in |
| 2612 | // if the screen opened before the ledger answered. |
| 2613 | if (!$("create-plan").classList.contains("hidden")) renderPlanTiers(); |
| 2614 | consumeUpgradeLink(); |
| 2615 | }) |
| 2616 | .catch(() => { /* ledger unreachable — render without the affordance */ }); |
| 2617 | } |
| 2618 | |
| 2619 | // consumeUpgradeLink honours a #upgrade=<host> deep link (the space menu's |
| 2620 | // Upgrade entry) once the ledger is in: opens the tier picker for that space |
| 2621 | // if it's this account's and still free. Cleared either way, so a stale |
| 2622 | // fragment can't re-fire the sheet on later renders. |
| 2623 | function consumeUpgradeLink() { |
| 2624 | if (!upgradeLinkHost || !vault) return; |
| 2625 | const target = upgradeLinkHost; |
| 2626 | upgradeLinkHost = ""; |
| 2627 | history.replaceState(null, "", location.pathname + location.search); |
| 2628 | const s = (vault.obj.servers || []).find((sv) => { |
| 2629 | try { return new URL(sv.url).host.toLowerCase() === target; } catch { return false; } |
| 2630 | }); |
| 2631 | const ledger = provisioned && provisioned[target]; |
| 2632 | if (s && ledger && ledger.entitlement !== "paid") openUpgrade(s, true); |
| 2633 | } |
| 2634 | |
| 2635 | // ---- archive: undo banner + entry point -------------------------------------- |
| 2636 | // pendingUndo holds the pre-archive servers snapshot so a single tap fully |
| 2637 | // restores the entry (its star + exact position), not just the archived flag. |
| 2638 | let pendingUndo = null; // { name, prev } | null |
| 2639 | let undoTimer = 0; |
| 2640 | |
| 2641 | function clearUndo() { |
| 2642 | if (undoTimer) { clearTimeout(undoTimer); undoTimer = 0; } |
| 2643 | pendingUndo = null; |
| 2644 | } |
| 2645 | |
| 2646 | function undoBanner() { |
| 2647 | const p = document.createElement("p"); |
| 2648 | p.className = "undo-note"; |
| 2649 | const span = document.createElement("span"); |
| 2650 | span.textContent = "Archived " + pendingUndo.name + ". "; |
| 2651 | const btn = document.createElement("button"); |
| 2652 | btn.type = "button"; |
| 2653 | btn.className = "linkish"; |
| 2654 | btn.textContent = "Undo"; |
| 2655 | btn.addEventListener("click", undoArchive); |
| 2656 | p.append(span, btn); |
| 2657 | return p; |
| 2658 | } |
| 2659 | |
| 2660 | // A muted archive-box glyph (static, in-code SVG — safe for innerHTML), matching |
| 2661 | // the quiet grip: a lidded box with a handle slot. |
| 2662 | const ARCHIVE_SVG = |
| 2663 | '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none" ' + |
| 2664 | 'stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">' + |
| 2665 | '<rect x="2" y="2.5" width="12" height="3" rx="0.8"/>' + |
| 2666 | '<path d="M3 5.5v7a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-7"/>' + |
| 2667 | '<path d="M6.3 8.3h3.4"/></svg>'; |
| 2668 | |
| 2669 | // The foot-of-list button that opens the archive; shown only when something's in |
| 2670 | // it. The count keeps the archive discoverable without cluttering the main list. |
| 2671 | function archivedButton(n) { |
| 2672 | const btn = document.createElement("button"); |
| 2673 | btn.type = "button"; |
| 2674 | btn.className = "archived-open"; |
| 2675 | btn.addEventListener("click", openArchive); |
| 2676 | const label = document.createElement("span"); |
| 2677 | label.innerHTML = ARCHIVE_SVG; |
| 2678 | const text = document.createElement("span"); |
| 2679 | text.textContent = "Archived (" + n + ")"; |
| 2680 | label.append(text); |
| 2681 | const chev = document.createElement("span"); |
| 2682 | chev.className = "chev"; |
| 2683 | chev.setAttribute("aria-hidden", "true"); |
| 2684 | chev.textContent = "›"; |
| 2685 | btn.append(label, chev); |
| 2686 | return btn; |
| 2687 | } |
| 2688 | |
| 2689 | function archiveSpace(s) { |
| 2690 | clearUndo(); |
| 2691 | const prev = vault.obj.servers; |
| 2692 | pendingUndo = { name: labelOf(s), prev }; // set before applyServers so the banner renders |
| 2693 | undoTimer = setTimeout(() => { pendingUndo = null; if (vault) showVault(); }, 8000); |
| 2694 | applyServers(setArchived(prev, s.url, true)); // re-renders (with the banner) + persists |
| 2695 | } |
| 2696 | |
| 2697 | function undoArchive() { |
| 2698 | if (!pendingUndo) return; |
| 2699 | const prev = pendingUndo.prev; |
| 2700 | clearUndo(); |
| 2701 | applyServers(prev); // restore the exact prior array (star + position), re-render, persist |
| 2702 | } |
| 2703 | |
| 2704 | function emptyLine(text) { |
| 2705 | const p = document.createElement("p"); |
| 2706 | p.className = "empty"; |
| 2707 | p.textContent = text; |
| 2708 | return p; |
| 2709 | } |
| 2710 | |
| 2711 | // Which method is holding THIS session up — the persisted session records it; |
| 2712 | // the last-signin marker covers a tab-only (unpersisted) session. "" when |
| 2713 | // unknowable. |
| 2714 | function sessionMethod() { |
| 2715 | const s = persistedSession(); |
| 2716 | if (s && s.method) return s.method; |
| 2717 | try { return localStorage.getItem("home_last_signin") || ""; } catch { return ""; } |
| 2718 | } |
| 2719 | |
| 2720 | // The wrong-door landing: an Apple/email sign-in that resolves a brand-new |
| 2721 | // account shows an empty list — but this person's spaces may live under the |
| 2722 | // passkey they used before anchors existed. Offer the bridge in place: the |
| 2723 | // ordinary passkey door, run from the signed-in view. signIn() touches no |
| 2724 | // state until the ceremony succeeds, so cancelling leaves this session |
| 2725 | // exactly as it was; success swaps to the passkey account, where Sign-in |
| 2726 | // options → Add pulls this anchor over (the combine flow). |
| 2727 | function bringOverLine() { |
| 2728 | const p = document.createElement("p"); |
| 2729 | p.className = "empty"; |
| 2730 | const b = document.createElement("button"); |
| 2731 | b.type = "button"; |
| 2732 | b.className = "passkey-link"; |
| 2733 | b.textContent = "Sign in with your passkey"; |
| 2734 | b.addEventListener("click", () => { |
| 2735 | b.disabled = true; |
| 2736 | vaultStatus("Confirming it’s you…", "busy"); |
| 2737 | signIn().then(() => vaultStatus(""), () => { |
| 2738 | vaultStatus("That passkey didn’t confirm — nothing has changed.", "error"); |
| 2739 | }).finally(() => { b.disabled = false; setSignedOutBusy(false); setStatus(""); }); |
| 2740 | }); |
| 2741 | p.append("Already have spaces? ", b, " to bring them over."); |
| 2742 | return p; |
| 2743 | } |
| 2744 | |
| 2745 | function sectionHeading(text) { |
| 2746 | const h = document.createElement("div"); |
| 2747 | h.className = "spaces-head"; |
| 2748 | h.textContent = text; |
| 2749 | return h; |
| 2750 | } |
| 2751 | |
| 2752 | function serverList(group, sortable) { |
| 2753 | const ul = document.createElement("ul"); |
| 2754 | ul.className = "servers"; |
| 2755 | for (const s of group) ul.append(serverRow(s, sortable)); |
| 2756 | return ul; |
| 2757 | } |
| 2758 | |
| 2759 | // A quiet 6-dot grip (static, in-code SVG — safe for innerHTML). iOS-style, it |
| 2760 | // lives on the right so the star stays the left anchor; it surfaces on hover. |
| 2761 | const GRIP_SVG = |
| 2762 | '<svg viewBox="0 0 10 16" width="10" height="16" aria-hidden="true">' + |
| 2763 | '<circle cx="2" cy="3" r="1.25"/><circle cx="8" cy="3" r="1.25"/>' + |
| 2764 | '<circle cx="2" cy="8" r="1.25"/><circle cx="8" cy="8" r="1.25"/>' + |
| 2765 | '<circle cx="2" cy="13" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>'; |
| 2766 | |
| 2767 | function serverRow(s, sortable) { |
| 2768 | const li = document.createElement("li"); |
| 2769 | li.dataset.url = s.url; |
| 2770 | |
| 2771 | const star = document.createElement("button"); |
| 2772 | star.type = "button"; |
| 2773 | star.className = "star" + (s.starred ? " on" : ""); |
| 2774 | star.textContent = s.starred ? "★" : "☆"; |
| 2775 | star.setAttribute("aria-label", (s.starred ? "Unstar " : "Star ") + labelOf(s)); |
| 2776 | // Star/reorder act on the ACTIVE list only; archived entries ride along at the |
| 2777 | // tail untouched (they never render a star or grip). |
| 2778 | star.addEventListener("click", () => { |
| 2779 | const { active, archived } = partitionArchived(vault.obj.servers); |
| 2780 | applyServers([...toggleStar(active, s.url), ...archived]); |
| 2781 | }); |
| 2782 | |
| 2783 | const main = document.createElement("div"); |
| 2784 | main.className = "srv-main"; |
| 2785 | const name = document.createElement("a"); |
| 2786 | name.className = "name"; |
| 2787 | // A clean browser has no per-space local session yet. The marker asks the |
| 2788 | // destination to begin its encrypted restore handoff instead of bouncing |
| 2789 | // straight back here; restore.js reuses this tab's unlocked home session. |
| 2790 | name.href = spaceRestoreURL(s.url); |
| 2791 | name.textContent = labelOf(s); |
| 2792 | // Spaces go by name here — the address stays off the row. It survives as a |
| 2793 | // hover tooltip, which also tells twin-named spaces apart. (Unnamed spaces |
| 2794 | // already show their host as the label, so the tooltip would just repeat it.) |
| 2795 | if (s.name) name.title = hostOf(s.url); |
| 2796 | main.append(name); |
| 2797 | |
| 2798 | // Billing state, for spaces this account created (the provisioner's ledger): |
| 2799 | // upgraded rows carry a quiet Supporter badge; free ones offer Upgrade. |
| 2800 | const ledger = billingEnabled && provisioned ? provisioned[hostOf(s.url)] : null; |
| 2801 | if (ledger && ledger.entitlement === "paid") { |
| 2802 | // The tier badge with Manage right beside it — the subscription's label |
| 2803 | // and its controls read as one unit. |
| 2804 | const row = document.createElement("span"); |
| 2805 | row.className = "badge-row"; |
| 2806 | const badge = document.createElement("span"); |
| 2807 | badge.className = "supporter-badge"; |
| 2808 | badge.textContent = tierName(ledger.tier); |
| 2809 | const manage = document.createElement("button"); |
| 2810 | manage.type = "button"; |
| 2811 | manage.className = "row-manage"; |
| 2812 | manage.textContent = "Manage"; |
| 2813 | manage.setAttribute("aria-label", "Manage the subscription for " + labelOf(s)); |
| 2814 | manage.addEventListener("click", () => openPortal(hostOf(s.url), manage)); |
| 2815 | row.append(badge, manage); |
| 2816 | // A cancelled subscription runs out at paid_until — say so, quietly. |
| 2817 | if (ledger.cancel_at_period_end && ledger.paid_until) { |
| 2818 | const ends = document.createElement("span"); |
| 2819 | ends.className = "sub-ends"; |
| 2820 | ends.textContent = "ends " + new Date(ledger.paid_until * 1000) |
| 2821 | .toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); |
| 2822 | row.append(ends); |
| 2823 | } |
| 2824 | main.append(row); |
| 2825 | } |
| 2826 | |
| 2827 | // The storage meter's reading (spaces this account created; 0 = not yet |
| 2828 | // metered), with the space's storage allowance when one is set. |
| 2829 | const metered = provisioned ? provisioned[hostOf(s.url)] : null; |
| 2830 | if (metered && metered.storage_bytes > 0) { |
| 2831 | const usage = document.createElement("span"); |
| 2832 | usage.className = "srv-usage"; |
| 2833 | usage.textContent = metered.storage_cap > 0 |
| 2834 | ? fmtBytes(metered.storage_bytes) + " of " + fmtBytes(metered.storage_cap) + " stored" |
| 2835 | : fmtBytes(metered.storage_bytes) + " stored"; |
| 2836 | main.append(usage); |
| 2837 | } |
| 2838 | |
| 2839 | li.append(star, main); |
| 2840 | |
| 2841 | if (ledger && ledger.entitlement !== "paid") { |
| 2842 | const up = document.createElement("button"); |
| 2843 | up.type = "button"; |
| 2844 | up.className = "row-upgrade"; |
| 2845 | up.textContent = "Upgrade"; |
| 2846 | up.setAttribute("aria-label", "Upgrade " + labelOf(s)); |
| 2847 | up.addEventListener("click", () => openUpgrade(s)); |
| 2848 | li.append(up); |
| 2849 | } |
| 2850 | |
| 2851 | // Archive: a quiet box glyph that reveals on hover (like the grip). One tap |
| 2852 | // sets it aside (reversible via the inline Undo); no confirmation — the archive |
| 2853 | // is where the permanent, strongly-worded delete lives. |
| 2854 | const arch = document.createElement("button"); |
| 2855 | arch.type = "button"; |
| 2856 | arch.className = "row-archive"; |
| 2857 | arch.setAttribute("aria-label", "Archive " + labelOf(s)); |
| 2858 | arch.innerHTML = ARCHIVE_SVG; |
| 2859 | arch.addEventListener("click", () => archiveSpace(s)); |
| 2860 | li.append(arch); |
| 2861 | |
| 2862 | // The drag handle: grab to reorder (pointer), or ↑/↓ when focused (keyboard) — |
| 2863 | // the a11y reorder fallback. Omitted while a search filter is active (you can't |
| 2864 | // meaningfully reorder a filtered subset). |
| 2865 | if (sortable) { |
| 2866 | const handle = document.createElement("button"); |
| 2867 | handle.type = "button"; |
| 2868 | handle.className = "drag-handle"; |
| 2869 | handle.setAttribute("aria-label", "Reorder " + labelOf(s)); |
| 2870 | handle.innerHTML = GRIP_SVG; |
| 2871 | handle.addEventListener("pointerdown", (e) => startDrag(e, li)); |
| 2872 | handle.addEventListener("keydown", (e) => { |
| 2873 | const dir = e.key === "ArrowUp" ? -1 : e.key === "ArrowDown" ? 1 : 0; |
| 2874 | if (!dir) return; |
| 2875 | e.preventDefault(); |
| 2876 | const { active, archived } = partitionArchived(vault.obj.servers); |
| 2877 | applyServers([...moveInGroup(active, s.url, dir), ...archived]); |
| 2878 | focusHandle(s.url); // re-render dropped focus; put it back on the moved row |
| 2879 | }); |
| 2880 | li.append(handle); |
| 2881 | } |
| 2882 | |
| 2883 | return li; |
| 2884 | } |
| 2885 | |
| 2886 | function focusHandle(url) { |
| 2887 | for (const li of $("spaces").querySelectorAll("li")) { |
| 2888 | if (li.dataset.url === url) { li.querySelector(".drag-handle")?.focus(); return; } |
| 2889 | } |
| 2890 | } |
| 2891 | |
| 2892 | // ---- drag to reorder ---------------------------------------------------------- |
| 2893 | // Pointer-based (mouse + touch): the grabbed row lifts and follows the pointer |
| 2894 | // while its siblings slide aside (FLIP). Drag is confined to its own list, so the |
| 2895 | // Starred and other lists sort independently; the star toggle moves rows between |
| 2896 | // them. prefers-reduced-motion drops the slide + settle animations. |
| 2897 | |
| 2898 | let drag = null; |
| 2899 | |
| 2900 | function startDrag(e, li) { |
| 2901 | if (e.pointerType === "mouse" && e.button !== 0) return; |
| 2902 | e.preventDefault(); |
| 2903 | const handle = e.currentTarget; |
| 2904 | const rect = li.getBoundingClientRect(); |
| 2905 | drag = { li, ul: li.parentElement, handle, pointerId: e.pointerId, grab: e.clientY - rect.top, moved: false }; |
| 2906 | handle.setPointerCapture(e.pointerId); |
| 2907 | li.classList.add("dragging"); |
| 2908 | handle.addEventListener("pointermove", onDragMove); |
| 2909 | handle.addEventListener("pointerup", endDrag); |
| 2910 | handle.addEventListener("pointercancel", endDrag); |
| 2911 | } |
| 2912 | |
| 2913 | function onDragMove(e) { |
| 2914 | if (!drag) return; |
| 2915 | drag.moved = true; |
| 2916 | const { li, ul } = drag; |
| 2917 | // Which sibling should we sit above? The first whose midpoint is below us. |
| 2918 | let ref = null; |
| 2919 | for (const r of ul.children) { |
| 2920 | if (r === li || r.tagName !== "LI") continue; |
| 2921 | const rc = r.getBoundingClientRect(); |
| 2922 | if (e.clientY < rc.top + rc.height / 2) { ref = r; break; } |
| 2923 | } |
| 2924 | const changes = ref ? li.nextElementSibling !== ref : ul.lastElementChild !== li; |
| 2925 | if (changes) flipReorder(ul, li, () => (ref ? ul.insertBefore(li, ref) : ul.append(li))); |
| 2926 | // Glue the lifted row under the pointer (clear transform first to read its |
| 2927 | // natural post-reorder position). |
| 2928 | li.style.transition = "none"; |
| 2929 | li.style.transform = ""; |
| 2930 | const top = li.getBoundingClientRect().top; |
| 2931 | li.style.transform = "translateY(" + (e.clientY - drag.grab - top) + "px)"; |
| 2932 | } |
| 2933 | |
| 2934 | function endDrag() { |
| 2935 | if (!drag) return; |
| 2936 | const { li, handle, pointerId, moved } = drag; |
| 2937 | handle.releasePointerCapture?.(pointerId); |
| 2938 | handle.removeEventListener("pointermove", onDragMove); |
| 2939 | handle.removeEventListener("pointerup", endDrag); |
| 2940 | handle.removeEventListener("pointercancel", endDrag); |
| 2941 | li.style.transition = reducedMotion() ? "none" : "transform 160ms ease"; |
| 2942 | li.style.transform = ""; // settle back into the slot the finger left it in |
| 2943 | li.classList.remove("dragging"); |
| 2944 | drag = null; |
| 2945 | if (moved) commitOrder(); |
| 2946 | } |
| 2947 | |
| 2948 | // flipReorder runs `mutate` (the DOM reorder) and animates every row it displaced |
| 2949 | // from its old position to its new one — the "slide aside" that makes the reorder |
| 2950 | // read as physical rather than a jump. The dragged row is excluded (it's glued to |
| 2951 | // the pointer separately). |
| 2952 | function flipReorder(ul, exclude, mutate) { |
| 2953 | if (reducedMotion()) { mutate(); return; } |
| 2954 | const rows = [...ul.children].filter((el) => el.tagName === "LI" && el !== exclude); |
| 2955 | const before = new Map(rows.map((el) => [el, el.getBoundingClientRect().top])); |
| 2956 | mutate(); |
| 2957 | for (const el of rows) { |
| 2958 | const dy = before.get(el) - el.getBoundingClientRect().top; |
| 2959 | if (!dy) continue; |
| 2960 | el.style.transition = "none"; |
| 2961 | el.style.transform = "translateY(" + dy + "px)"; |
| 2962 | requestAnimationFrame(() => { |
| 2963 | el.style.transition = "transform 160ms ease"; |
| 2964 | el.style.transform = ""; |
| 2965 | }); |
| 2966 | } |
| 2967 | } |
| 2968 | |
| 2969 | // commitOrder reads the on-screen order back into the vault and saves — only if it |
| 2970 | // actually changed (a grab-without-move shouldn't write). |
| 2971 | function commitOrder() { |
| 2972 | const order = []; |
| 2973 | for (const ul of $("spaces").querySelectorAll("ul.servers")) { |
| 2974 | for (const li of ul.querySelectorAll("li")) order.push(li.dataset.url); |
| 2975 | } |
| 2976 | const { active, archived } = partitionArchived(vault.obj.servers || []); |
| 2977 | const next = orderServers(active, order); |
| 2978 | if (next.length === active.length && next.every((s, i) => s.url === active[i].url)) return; |
| 2979 | vault.obj.servers = [...next, ...archived]; |
| 2980 | persistVault(); |
| 2981 | } |
| 2982 | |
| 2983 | function reducedMotion() { |
| 2984 | return matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 2985 | } |
| 2986 | |
| 2987 | // applyServers swaps in a new servers array (star/keyboard-move returns a fresh |
| 2988 | // one), re-renders immediately (optimistic), then persists in the background. |
| 2989 | function applyServers(next) { |
| 2990 | vault.obj.servers = next; |
| 2991 | showVault(); |
| 2992 | persistVault(); |
| 2993 | } |
| 2994 | |
| 2995 | async function persistVault(status = vaultStatus) { |
| 2996 | status("Saving…", "busy"); |
| 2997 | try { |
| 2998 | await saveVault(); |
| 2999 | status("Saved.", "ok"); |
| 3000 | } catch (e) { |
| 3001 | status(e.message || "Couldn't save your changes.", "error"); |
| 3002 | } |
| 3003 | } |
| 3004 | |
| 3005 | // ---- upgrade a space ---------------------------------------------------------- |
| 3006 | |
| 3007 | function upgradeStatus(msg, kind) { |
| 3008 | const el = $("upgrade-status"); |
| 3009 | el.textContent = msg || ""; |
| 3010 | el.className = "status" + (kind ? " " + kind : ""); |
| 3011 | } |
| 3012 | |
| 3013 | // tierName maps a ledger tier key to its member-facing name; spaces upgraded |
| 3014 | // under a tier that's since left the price book still get a badge. |
| 3015 | function tierName(key) { |
| 3016 | const t = billingTiers.find((x) => x.key === key); |
| 3017 | return (t && t.name) || "Supporter"; |
| 3018 | } |
| 3019 | |
| 3020 | // The picker's band + ≈ state. The band resets to base (or a deep link) per |
| 3021 | // open; the currency override is session-only — nothing about the buyer is |
| 3022 | // stored, matching the honor design (docs/pricing-bands.md). |
| 3023 | let selectedBand = "base"; |
| 3024 | let approxCurrency = ""; // "" = follow the browser locale; "EUR" = hide ≈ |
| 3025 | let fxSnapshot = null; // {date, rates} once /api/fx answers; null before |
| 3026 | |
| 3027 | // openUpgrade shows the plan picker for one space. The payment itself is |
| 3028 | // Stripe's hosted page; this screen only chooses the plan and hands off. |
| 3029 | // fromLink keeps the deep link's &tier= highlight; a manual open drops it. |
| 3030 | function openUpgrade(s, fromLink) { |
| 3031 | if (!fromLink) tierLink = ""; |
| 3032 | hideVault(); |
| 3033 | $("upgrade").classList.remove("hidden"); |
| 3034 | $("upgrade-host").textContent = hostOf(s.url); |
| 3035 | upgradeStatus(""); |
| 3036 | if (bandLink) { |
| 3037 | selectedBand = bandLink; |
| 3038 | bandLink = ""; |
| 3039 | history.replaceState(null, "", location.pathname + location.search); |
| 3040 | } |
| 3041 | // The ≈ rates: one fetch per page life, rendered in when they land. The |
| 3042 | // picker is complete without them — an absent estimate beats a wrong one. |
| 3043 | if (!fxSnapshot) { |
| 3044 | fxSnapshot = { date: "", rates: null }; |
| 3045 | apiJSON("/api/fx") |
| 3046 | .then((b) => { |
| 3047 | fxSnapshot = { date: b.date || "", rates: b.rates || null }; |
| 3048 | if (!$("upgrade").classList.contains("hidden")) renderUpgrade(s); |
| 3049 | }) |
| 3050 | .catch(() => { /* no rates — the EUR stickers stand alone */ }); |
| 3051 | } |
| 3052 | renderUpgrade(s); |
| 3053 | } |
| 3054 | |
| 3055 | // renderUpgrade paints the whole picker for the selected band: the band row |
| 3056 | // (only when the price book actually offers bands), each tier at that band's |
| 3057 | // price with its ≈ local figure, the honor line, and the +15% review promise. |
| 3058 | function renderUpgrade(s) { |
| 3059 | const offered = bandsOffered(billingTiers); |
| 3060 | const banded = offered.length > 1; |
| 3061 | if (!offered.includes(selectedBand)) selectedBand = "base"; |
| 3062 | |
| 3063 | const row = $("upgrade-bands"); |
| 3064 | row.textContent = ""; |
| 3065 | row.classList.toggle("hidden", !banded); |
| 3066 | if (banded) { |
| 3067 | for (const key of offered) { |
| 3068 | const chip = document.createElement("button"); |
| 3069 | chip.type = "button"; |
| 3070 | chip.className = "band-chip" + (key === selectedBand ? " selected" : ""); |
| 3071 | chip.setAttribute("aria-pressed", key === selectedBand ? "true" : "false"); |
| 3072 | chip.textContent = bandName(key); |
| 3073 | chip.addEventListener("click", () => { |
| 3074 | selectedBand = key; |
| 3075 | renderUpgrade(s); |
| 3076 | }); |
| 3077 | row.append(chip); |
| 3078 | } |
| 3079 | } |
| 3080 | const note = $("upgrade-band-note"); |
| 3081 | note.textContent = banded |
| 3082 | ? BANDS.find((b) => b.key === selectedBand).line + " Pick the price that's right for you — no questions asked." |
| 3083 | : ""; |
| 3084 | |
| 3085 | const currency = approxCurrency || regionCurrency(navigator.language); |
| 3086 | const rates = fxSnapshot && fxSnapshot.rates; |
| 3087 | const box = $("upgrade-tiers"); |
| 3088 | box.textContent = ""; |
| 3089 | // The listing said this space isn't paid, but its region advertised no |
| 3090 | // tiers to sell (billing not configured there, or momentarily unreachable) |
| 3091 | // — say so plainly rather than leaving a silent gap where the plan |
| 3092 | // buttons belong (openUpgrade only gets here once ownership + free-tier |
| 3093 | // are already confirmed, so the sheet itself is warranted). |
| 3094 | if (!billingTiers.length) { |
| 3095 | upgradeStatus("Upgrading isn't available for this space right now.", "error"); |
| 3096 | $("upgrade-fx").classList.add("hidden"); |
| 3097 | $("upgrade-cap").textContent = ""; |
| 3098 | return; |
| 3099 | } |
| 3100 | let anyApprox = false; |
| 3101 | for (const t of billingTiers) { |
| 3102 | const view = tierForBand(t, selectedBand); |
| 3103 | const btn = document.createElement("button"); |
| 3104 | btn.type = "button"; |
| 3105 | btn.className = "tier-btn"; |
| 3106 | const name = document.createElement("span"); |
| 3107 | name.className = "tier-name"; |
| 3108 | name.textContent = t.name; |
| 3109 | const price = document.createElement("span"); |
| 3110 | price.className = "tier-price"; |
| 3111 | // Business stickers are ex-VAT wherever they appear (docs/pricing-bands.md § VAT). |
| 3112 | price.textContent = view.label + (t.business ? " excl. VAT" : ""); |
| 3113 | btn.append(name, price); |
| 3114 | const approx = currency === "EUR" ? "" : approxLocal(view.eur, currency, rates, navigator.language); |
| 3115 | if (approx) { |
| 3116 | anyApprox = true; |
| 3117 | const a = document.createElement("span"); |
| 3118 | a.className = "tier-approx"; |
| 3119 | a.textContent = approx; |
| 3120 | btn.append(a); |
| 3121 | } |
| 3122 | // A tier without the selected band sells at its standard price — said |
| 3123 | // plainly, never implied to be reduced. |
| 3124 | if (banded && !view.offered) { |
| 3125 | const std = document.createElement("span"); |
| 3126 | std.className = "tier-band-std"; |
| 3127 | std.textContent = "standard price"; |
| 3128 | btn.append(std); |
| 3129 | } |
| 3130 | // The deep link's chosen tier (the setup conversation's plan chip): |
| 3131 | // highlighted, not auto-bought — checkout stays the user's tap. |
| 3132 | if (tierLink && tierLink === t.key) { |
| 3133 | btn.classList.add("selected"); |
| 3134 | btn.setAttribute("aria-pressed", "true"); |
| 3135 | } |
| 3136 | btn.addEventListener("click", () => startUpgrade(hostOf(s.url), t.key, view.band, box)); |
| 3137 | box.append(btn); |
| 3138 | } |
| 3139 | |
| 3140 | // The ≈ attribution + currency override. Shown whenever rates exist and a |
| 3141 | // display currency is in play — including "EUR" (≈ hidden), or picking EUR |
| 3142 | // would make the override itself vanish with no way back. Euro-area locales |
| 3143 | // with no override see none of this: they're billed in their own currency. |
| 3144 | const fxNote = $("upgrade-fx"); |
| 3145 | fxNote.textContent = ""; |
| 3146 | const showFx = !!rates && !!currency; |
| 3147 | fxNote.classList.toggle("hidden", !showFx); |
| 3148 | if (showFx) { |
| 3149 | const span = document.createElement("span"); |
| 3150 | span.textContent = anyApprox ? "Billed in euros — ≈ is an estimate in " : "Billed in euros — show ≈ in "; |
| 3151 | const sel = document.createElement("select"); |
| 3152 | sel.className = "fx-currency"; |
| 3153 | sel.setAttribute("aria-label", "Currency for the approximate figures"); |
| 3154 | for (const c of [...Object.keys(rates), "EUR"].sort()) { |
| 3155 | const opt = document.createElement("option"); |
| 3156 | opt.value = c; |
| 3157 | opt.textContent = c; |
| 3158 | if (c === currency) opt.selected = true; |
| 3159 | sel.append(opt); |
| 3160 | } |
| 3161 | sel.addEventListener("change", () => { |
| 3162 | approxCurrency = sel.value; |
| 3163 | renderUpgrade(s); |
| 3164 | }); |
| 3165 | const tail = document.createElement("span"); |
| 3166 | tail.textContent = anyApprox && fxSnapshot.date ? " at the ECB rate of " + fxSnapshot.date + "." : "."; |
| 3167 | fxNote.append(span, sel, tail); |
| 3168 | } |
| 3169 | |
| 3170 | // The +15% annual-review promise, in money (never a bare percent): the |
| 3171 | // first tier at the selected band is the concrete example. |
| 3172 | const cap = $("upgrade-cap"); |
| 3173 | const first = billingTiers[0] && tierForBand(billingTiers[0], selectedBand); |
| 3174 | const example = first && first.eur ? capNote(first.eur, navigator.language) : ""; |
| 3175 | cap.textContent = "Every price is reviewed once a year" + |
| 3176 | (example ? " — " + billingTiers[0].name + ": " + example : "") + |
| 3177 | ". Rises above 15% only ever happen with your explicit yes; decreases happen by themselves."; |
| 3178 | } |
| 3179 | |
| 3180 | function closeUpgrade() { |
| 3181 | $("upgrade").classList.add("hidden"); |
| 3182 | showVault(); |
| 3183 | } |
| 3184 | |
| 3185 | // startUpgrade begins the checkout round trip for the chosen plan + band: |
| 3186 | // home mints a Stripe Checkout link (ownership enforced by the provisioner's |
| 3187 | // ledger) and we follow it in the same tab — Stripe brings you back here with |
| 3188 | // ?upgraded=<host> when it's done, and Cancel comes straight back too. |
| 3189 | async function startUpgrade(host, tier, band, box) { |
| 3190 | for (const b of box.querySelectorAll("button")) b.disabled = true; |
| 3191 | upgradeStatus("Opening checkout…", "busy"); |
| 3192 | try { |
| 3193 | const res = await fetch("/api/spaces/" + encodeURIComponent(host) + "/checkout", { |
| 3194 | method: "POST", |
| 3195 | headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" }, |
| 3196 | body: JSON.stringify({ tier, band }), |
| 3197 | }); |
| 3198 | const body = await res.json().catch(() => ({})); |
| 3199 | if (!res.ok) throw new Error(body.message || body.error || `Couldn't start the upgrade (HTTP ${res.status}).`); |
| 3200 | if (!body.url) throw new Error("The checkout link didn't come back — try again."); |
| 3201 | location.href = body.url; |
| 3202 | } catch (e) { |
| 3203 | upgradeStatus(e.message || "Couldn't start the upgrade.", "error"); |
| 3204 | for (const b of box.querySelectorAll("button")) b.disabled = false; |
| 3205 | } |
| 3206 | } |
| 3207 | |
| 3208 | // ---- keep (claim) a demo space -------------------------------------------------- |
| 3209 | |
| 3210 | function claimStatus(msg, kind) { |
| 3211 | const el = $("claim-status"); |
| 3212 | el.textContent = msg || ""; |
| 3213 | el.className = "status" + (kind ? " " + kind : ""); |
| 3214 | } |
| 3215 | |
| 3216 | // consumeClaimLink honours a #claim=<host> deep link (the demo banner's "Keep |
| 3217 | // this space") once signed in: opens the claim screen for that host. Cleared |
| 3218 | // either way, so a stale fragment can't re-fire the screen on later renders. |
| 3219 | function consumeClaimLink() { |
| 3220 | if (!claimLinkHost || !vault) return; |
| 3221 | const host = claimLinkHost; |
| 3222 | claimLinkHost = ""; |
| 3223 | history.replaceState(null, "", location.pathname + location.search); |
| 3224 | openClaim(host); |
| 3225 | } |
| 3226 | |
| 3227 | // openClaim shows the one-confirm claim screen. No pre-checks here — the |
| 3228 | // provisioner's answers (quota, not-a-demo, expired) are member-facing and |
| 3229 | // shown verbatim, so asking twice would only add a failure mode. |
| 3230 | function openClaim(host) { |
| 3231 | hideVault(); |
| 3232 | $("claim").classList.remove("hidden"); |
| 3233 | $("claim-host").textContent = host; |
| 3234 | claimStatus(""); |
| 3235 | const go = $("claim-go"); |
| 3236 | go.hidden = false; |
| 3237 | go.disabled = false; |
| 3238 | go.onclick = () => doClaim(host, go); |
| 3239 | $("claim-open").classList.add("hidden"); |
| 3240 | } |
| 3241 | |
| 3242 | function closeClaim() { |
| 3243 | $("claim").classList.add("hidden"); |
| 3244 | showVault(); |
| 3245 | } |
| 3246 | |
| 3247 | // doClaim is the real call: home relays to the provisioner (ownership |
| 3248 | // transfer, quota, expiry guard), then the space folds into this account's |
| 3249 | // encrypted list exactly like a create — sign-in always finds the way back. |
| 3250 | async function doClaim(host, btn) { |
| 3251 | btn.disabled = true; |
| 3252 | claimStatus("Claiming " + host + "…", "busy"); |
| 3253 | try { |
| 3254 | const res = await fetch("/api/spaces/claim", { |
| 3255 | method: "POST", |
| 3256 | headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" }, |
| 3257 | body: JSON.stringify({ host }), |
| 3258 | }); |
| 3259 | const body = await res.json().catch(() => ({})); |
| 3260 | if (!res.ok) throw new Error(body.message || body.error || `Couldn't claim this space (HTTP ${res.status}).`); |
| 3261 | const url = body.url || "https://" + host; |
| 3262 | vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url); |
| 3263 | vault.obj.servers.push({ url, name: host }); |
| 3264 | vault.obj.removed = clearTombstone(vault.obj.removed, url); // a deliberate add wins |
| 3265 | try { |
| 3266 | await saveVault(); |
| 3267 | } catch { |
| 3268 | // The claim stuck even if the vault save raced; the entry lands on the |
| 3269 | // next successful save. |
| 3270 | } |
| 3271 | provisioned = null; // the ledger now lists this space as this account's |
| 3272 | claimStatus("This space is yours now — the countdown is off, and it's saved to your list.", "ok"); |
| 3273 | btn.hidden = true; |
| 3274 | const open = $("claim-open"); |
| 3275 | open.href = url; |
| 3276 | open.classList.remove("hidden"); |
| 3277 | } catch (e) { |
| 3278 | claimStatus(e.message || "Couldn't claim this space.", "error"); |
| 3279 | btn.disabled = false; |
| 3280 | } |
| 3281 | } |
| 3282 | |
| 3283 | // openPortal hands off to Stripe's Billing Portal for one space — cancel, |
| 3284 | // change card, invoices. Same-tab round trip; the portal's Return link comes |
| 3285 | // back here. |
| 3286 | async function openPortal(host, btn) { |
| 3287 | btn.disabled = true; |
| 3288 | vaultStatus("Opening your billing page…", "busy"); |
| 3289 | try { |
| 3290 | const res = await fetch("/api/spaces/" + encodeURIComponent(host) + "/portal", { |
| 3291 | method: "POST", |
| 3292 | headers: { Authorization: "Bearer " + state.token }, |
| 3293 | }); |
| 3294 | const body = await res.json().catch(() => ({})); |
| 3295 | if (!res.ok) throw new Error(body.message || body.error || `Couldn't open the billing page (HTTP ${res.status}).`); |
| 3296 | if (!body.url) throw new Error("The billing link didn't come back — try again."); |
| 3297 | location.href = body.url; |
| 3298 | } catch (e) { |
| 3299 | vaultStatus(e.message || "Couldn't open the billing page.", "error"); |
| 3300 | btn.disabled = false; |
| 3301 | } |
| 3302 | } |
| 3303 | |
| 3304 | // ---- create a new space ------------------------------------------------------- |
| 3305 | |
| 3306 | // requestSpace is the real create call, fired by the fader the moment it snaps |
| 3307 | // to eleven: POST /api/spaces (link-token auth) — it returns only once the |
| 3308 | // server has confirmed the space is live and reachable, so the fader's build |
| 3309 | // phase IS the wait — then fold the new space into the vault so sign-in always |
| 3310 | // finds the way back. Throws a member-facing Error for the fader's error path. |
| 3311 | async function requestSpace() { |
| 3312 | if (!vault) throw new Error("Sign in first — your new space is saved into your account."); |
| 3313 | // The proof-of-work gate, when this home server runs one: fetch a challenge |
| 3314 | // and grind it out right here, during the fader's build phase — the CPU |
| 3315 | // burn IS the "Hold on…" moment. bits 0 = no gate, nothing to solve. |
| 3316 | const proof = await solveCreateChallenge(); |
| 3317 | // No name: a space is born nameless and Eleven asks inside it (the setup |
| 3318 | // conversation's "group" step), where the answer is a reply in a |
| 3319 | // conversation instead of a form field on the way in. Home learns the name |
| 3320 | // when the space's key is added to this account (the enrol payload's entry). |
| 3321 | const body = { ...(proof || {}) }; |
| 3322 | const res = await fetch("/api/spaces", { |
| 3323 | method: "POST", |
| 3324 | headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" }, |
| 3325 | body: JSON.stringify(body), |
| 3326 | }); |
| 3327 | const reply = await res.json().catch(() => ({})); |
| 3328 | if (!res.ok) { |
| 3329 | // Structured errors carry a member-facing message (quota, at-capacity). |
| 3330 | // The code rides along so the fader's error path can tell "paying would |
| 3331 | // fix this" (quota, pool pressure) from a plain failure (#1374). |
| 3332 | const err = new Error(reply.message || reply.error || `Couldn't create a space (HTTP ${res.status}).`); |
| 3333 | err.code = reply.error || ""; |
| 3334 | throw err; |
| 3335 | } |
| 3336 | const url = reply.url; |
| 3337 | const host = new URL(url).host; |
| 3338 | vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url); |
| 3339 | vault.obj.servers.push({ url, name: host }); |
| 3340 | vault.obj.removed = clearTombstone(vault.obj.removed, url); // a deliberate add wins |
| 3341 | try { |
| 3342 | await saveVault(); |
| 3343 | } catch { |
| 3344 | // The space exists even if the vault save raced — still confirm it; the |
| 3345 | // entry lands on the next successful save. |
| 3346 | } |
| 3347 | return { host, setupLink: reply.setup_link }; |
| 3348 | } |
| 3349 | |
| 3350 | // requestPaidSpace is the paid branch behind the fader: mint the Checkout for |
| 3351 | // the chosen plan and hand the tab to Stripe. The space is only created on |
| 3352 | // return (claimPaidCreate); a localStorage marker means even a closed tab can |
| 3353 | // finish next visit. The returned promise never resolves — navigation is the |
| 3354 | // resolution. |
| 3355 | async function requestPaidSpace() { |
| 3356 | if (!vault) throw new Error("Sign in first — your new space is saved into your account."); |
| 3357 | const proof = await solveCreateChallenge(); |
| 3358 | const res = await fetch("/api/spaces/checkout-create", { |
| 3359 | method: "POST", |
| 3360 | headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" }, |
| 3361 | body: JSON.stringify({ ...(proof || {}), tier: planChoice.tier }), |
| 3362 | }); |
| 3363 | const reply = await res.json().catch(() => ({})); |
| 3364 | if (!res.ok) throw new Error(reply.message || reply.error || `Couldn't start the checkout (HTTP ${res.status}).`); |
| 3365 | if (!reply.url) throw new Error("The checkout link didn't come back — try again."); |
| 3366 | if (reply.session_id) { |
| 3367 | rememberPendingCreate({ session: reply.session_id, at: Date.now() }); |
| 3368 | } |
| 3369 | location.href = reply.url; |
| 3370 | return new Promise(() => {}); |
| 3371 | } |
| 3372 | |
| 3373 | // ---- the pay-first marker + claim --------------------------------------------- |
| 3374 | |
| 3375 | // The localStorage marker for a checkout in flight: {session, at}. It |
| 3376 | // exists so a tab closed on Stripe's page still finishes the create on the |
| 3377 | // next visit here — the claim is idempotent, so over-firing is safe. |
| 3378 | const PENDING_CREATE_KEY = "lchat-pending-create"; |
| 3379 | const claimedSessions = new Set(); // auto-claim once per page life; Try again drives retries |
| 3380 | |
| 3381 | function rememberPendingCreate(m) { |
| 3382 | try { localStorage.setItem(PENDING_CREATE_KEY, JSON.stringify(m)); } catch { /* private mode — the redirect still carries the session */ } |
| 3383 | } |
| 3384 | function pendingCreateMarker() { |
| 3385 | try { return JSON.parse(localStorage.getItem(PENDING_CREATE_KEY) || "null"); } catch { return null; } |
| 3386 | } |
| 3387 | function clearPendingCreate() { |
| 3388 | try { localStorage.removeItem(PENDING_CREATE_KEY); } catch { /* nothing to clear */ } |
| 3389 | } |
| 3390 | |
| 3391 | // claimPaidCreate finishes a pay-first create: the provisioner verifies the |
| 3392 | // session with Stripe and provisions on the spot, so the create screen's |
| 3393 | // building beat is the honest wait. 402/404 mean the checkout never completed |
| 3394 | // (cancelled, expired, or another account's) — drop the marker quietly and |
| 3395 | // return to the list; the pending allocation prunes itself server-side. |
| 3396 | async function claimPaidCreate(session, region) { |
| 3397 | hideVault(); |
| 3398 | $("create-plan").classList.add("hidden"); |
| 3399 | $("create").classList.remove("hidden"); |
| 3400 | $("create-back").classList.add("hidden"); |
| 3401 | $("create-pitch").classList.add("hidden"); |
| 3402 | $("new-space-result").classList.add("hidden"); |
| 3403 | $("create-building").classList.remove("hidden"); |
| 3404 | try { |
| 3405 | const res = await fetch("/api/spaces/claim-create", { |
| 3406 | method: "POST", |
| 3407 | headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" }, |
| 3408 | body: JSON.stringify({ session_id: session, region: region || "" }), |
| 3409 | }); |
| 3410 | const reply = await res.json().catch(() => ({})); |
| 3411 | if (res.status === 402 || res.status === 404) { |
| 3412 | clearPendingCreate(); |
| 3413 | $("create-building").classList.add("hidden"); |
| 3414 | $("create").classList.add("hidden"); |
| 3415 | showVault(); |
| 3416 | return; |
| 3417 | } |
| 3418 | if (!res.ok) throw new Error(reply.message || reply.error || `Couldn't finish the setup (HTTP ${res.status}).`); |
| 3419 | const url = reply.url; |
| 3420 | const host = new URL(url).host; |
| 3421 | vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== url); |
| 3422 | vault.obj.servers.push({ url, name: host }); |
| 3423 | vault.obj.removed = clearTombstone(vault.obj.removed, url); |
| 3424 | try { await saveVault(); } catch { /* the space exists; the entry lands on the next save */ } |
| 3425 | clearPendingCreate(); |
| 3426 | $("create-building").classList.add("hidden"); |
| 3427 | showNewSpaceResult(host, reply.setup_link); |
| 3428 | } catch (e) { |
| 3429 | // Retriable (the provisioner said the payment is safe): keep the marker |
| 3430 | // and offer Try again right here. |
| 3431 | $("create-building").classList.add("hidden"); |
| 3432 | const body = $("new-space-body"); |
| 3433 | body.textContent = ""; |
| 3434 | const msg = document.createElement("p"); |
| 3435 | msg.className = "create-sub"; |
| 3436 | msg.textContent = e.message || "The space couldn't be set up just now — your payment is safe."; |
| 3437 | const retry = document.createElement("button"); |
| 3438 | retry.type = "button"; |
| 3439 | retry.className = "secondary"; |
| 3440 | retry.textContent = "Try again"; |
| 3441 | retry.addEventListener("click", () => claimPaidCreate(session, region)); |
| 3442 | body.append(msg, retry); |
| 3443 | $("create-back").classList.add("hidden"); |
| 3444 | $("new-space-result").classList.remove("hidden"); |
| 3445 | } |
| 3446 | } |
| 3447 | |
| 3448 | // solveCreateChallenge grinds the home server's hashcash challenge: find a |
| 3449 | // nonce where SHA-256(challenge + ":" + nonce) clears the difficulty. Async |
| 3450 | // digests yield to the event loop, so the fader's build animation keeps |
| 3451 | // breathing while we work. null when the server runs no gate. |
| 3452 | async function solveCreateChallenge() { |
| 3453 | const res = await fetch("/api/spaces/challenge", { |
| 3454 | method: "POST", |
| 3455 | headers: { Authorization: "Bearer " + state.token }, |
| 3456 | }); |
| 3457 | const c = await res.json().catch(() => ({})); |
| 3458 | if (!res.ok || !c.bits) return null; |
| 3459 | const enc = new TextEncoder(); |
| 3460 | for (let n = 0; ; n++) { |
| 3461 | const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(c.challenge + ":" + n))); |
| 3462 | if (leadingZeroBits(digest) >= c.bits) return { challenge: c.challenge, nonce: String(n) }; |
| 3463 | } |
| 3464 | } |
| 3465 | |
| 3466 | // tryDemo: the no-account path (POST /api/demo/challenge → grind → POST |
| 3467 | // /api/demo). The hashcash burn IS the wait, same trade as creation; every |
| 3468 | // outcome lands inline in #status (never a toast) — the server's messages are |
| 3469 | // member-facing (cooldown, demos-busy) and shown verbatim. On success we go |
| 3470 | // straight into the fresh space's setup link, same-tab like the create flow. |
| 3471 | async function tryDemo() { |
| 3472 | const btn = $("try-demo"); |
| 3473 | btn.disabled = true; |
| 3474 | setStatus("Preparing your demo space — a few seconds of math keeps the bots away…", "busy"); |
| 3475 | try { |
| 3476 | const res = await fetch("/api/demo/challenge", { method: "POST" }); |
| 3477 | const c = await res.json().catch(() => ({})); |
| 3478 | if (!res.ok) throw new Error(c.message || "Demos aren't available right now."); |
| 3479 | const enc = new TextEncoder(); |
| 3480 | let nonce = null; |
| 3481 | for (let n = 0; ; n++) { |
| 3482 | const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(c.challenge + ":" + n))); |
| 3483 | if (leadingZeroBits(digest) >= c.bits) { nonce = String(n); break; } |
| 3484 | } |
| 3485 | setStatus("Building your demo space…", "busy"); |
| 3486 | const create = await fetch("/api/demo", { |
| 3487 | method: "POST", |
| 3488 | headers: { "Content-Type": "application/json" }, |
| 3489 | body: JSON.stringify({ challenge: c.challenge, nonce }), |
| 3490 | }); |
| 3491 | const body = await create.json().catch(() => ({})); |
| 3492 | if (!create.ok) throw new Error(body.message || "Couldn't create a demo space right now."); |
| 3493 | location.href = body.setup_link; |
| 3494 | } catch (err) { |
| 3495 | setStatus(err.message, "error"); |
| 3496 | btn.disabled = false; |
| 3497 | } |
| 3498 | } |
| 3499 | $("try-demo").addEventListener("click", tryDemo); |
| 3500 | |
| 3501 | function leadingZeroBits(bytes) { |
| 3502 | let bits = 0; |
| 3503 | for (const b of bytes) { |
| 3504 | if (b === 0) { bits += 8; continue; } |
| 3505 | for (let mask = 128; mask; mask >>= 1) { |
| 3506 | if (b & mask) return bits; |
| 3507 | bits++; |
| 3508 | } |
| 3509 | } |
| 3510 | return bits; |
| 3511 | } |
| 3512 | |
| 3513 | // A checkmark for the single-serve confirmation (static, in-code SVG). |
| 3514 | const CHECK_SVG = |
| 3515 | '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" ' + |
| 3516 | 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 13l4 4L19 7"/></svg>'; |
| 3517 | |
| 3518 | // showNewSpaceResult fills the confirmation pane inside the create screen: a |
| 3519 | // sealed check, the new space's name, and the one-time setup link (the admin |
| 3520 | // flow — never the bare instance URL). The Done button is static in the markup. |
| 3521 | function showNewSpaceResult(host, setupLink) { |
| 3522 | const body = $("new-space-body"); |
| 3523 | body.textContent = ""; |
| 3524 | |
| 3525 | const check = document.createElement("div"); |
| 3526 | check.className = "ok-check"; |
| 3527 | check.innerHTML = CHECK_SVG; |
| 3528 | |
| 3529 | const title = document.createElement("p"); |
| 3530 | title.className = "created-name"; |
| 3531 | title.textContent = "Your space is ready"; |
| 3532 | |
| 3533 | const hostLine = document.createElement("p"); |
| 3534 | hostLine.className = "sub"; |
| 3535 | hostLine.textContent = host; |
| 3536 | body.append(check, title, hostLine); |
| 3537 | |
| 3538 | if (setupLink) { |
| 3539 | const a = document.createElement("a"); |
| 3540 | a.className = "btn-link"; |
| 3541 | // ?src=home tells the space's setup flow this person already holds an |
| 3542 | // Eleven sign-in, so its key step says "add to your passkey" instead of |
| 3543 | // pitching passkeys from scratch. Query param, not fragment: the secret |
| 3544 | // stays alone in the fragment. (Query never carries anything sensitive.) |
| 3545 | a.href = setupLink.replace("/join/setup#", "/join/setup?src=home#"); |
| 3546 | // Same tab, not a new one: setting up a space is where you're headed next, |
| 3547 | // and you'll come back here (to your list) once it's done — no reason to |
| 3548 | // strand this page behind a second tab. |
| 3549 | a.textContent = "Set up your space →"; |
| 3550 | const note = document.createElement("p"); |
| 3551 | note.className = "sub"; |
| 3552 | note.style.marginTop = "12px"; |
| 3553 | note.textContent = "This one-time link is only for you."; |
| 3554 | body.append(a, note); |
| 3555 | } else { |
| 3556 | // No setup link (mint failed) — deliberately not the bare instance URL, which |
| 3557 | // lands on a signed-out card rather than the admin flow. |
| 3558 | const note = document.createElement("p"); |
| 3559 | note.className = "sub"; |
| 3560 | note.textContent = "The setup link couldn't be created just now — head back and create again."; |
| 3561 | body.append(note); |
| 3562 | } |
| 3563 | |
| 3564 | $("create-back").classList.add("hidden"); // the confirmation's Done is the way out |
| 3565 | $("new-space-result").classList.remove("hidden"); |
| 3566 | } |
| 3567 | |
| 3568 | // ---- create-screen navigation ------------------------------------------------- |
| 3569 | |
| 3570 | // The fader mounts once (it builds its scale + meter into the DOM) and is |
| 3571 | // reset/started per visit; stop() parks its loop and clears the stage canvas. |
| 3572 | let fader = null; |
| 3573 | function ensureFader() { |
| 3574 | if (fader) return fader; |
| 3575 | fader = mountFader({ |
| 3576 | fader: $("fader"), |
| 3577 | meter: $("fader-meter"), |
| 3578 | shakeEl: document.querySelector("main.card"), |
| 3579 | canvas: $("stage-dust"), |
| 3580 | pitch: $("create-pitch"), |
| 3581 | building: $("create-building"), |
| 3582 | // The back link hides the moment the snap fires — the create is in flight |
| 3583 | // and can't be cancelled; error/ready each restore a way out. |
| 3584 | onEleven: () => { |
| 3585 | $("create-back").classList.add("hidden"); |
| 3586 | return planChoice && planChoice.tier ? requestPaidSpace() : requestSpace(); |
| 3587 | }, |
| 3588 | onReady: (r) => showNewSpaceResult(r.host, r.setupLink), |
| 3589 | onError: (err) => { |
| 3590 | // The two errors paying genuinely fixes — the free quota and the free |
| 3591 | // pool's budget gate — reopen the plan screen with the paid tiers |
| 3592 | // showing (#1374). Everything else stays a plain inline error. |
| 3593 | const code = (err && err.code) || ""; |
| 3594 | if ((code === "quota_exceeded" || code === "free_pool_full") && billingTiers.length) { |
| 3595 | openPlanBounce((err && err.message) || ""); |
| 3596 | return; |
| 3597 | } |
| 3598 | $("create-back").classList.remove("hidden"); |
| 3599 | spaceStatus((err && err.message) || String(err), "error"); |
| 3600 | }, |
| 3601 | }); |
| 3602 | return fader; |
| 3603 | } |
| 3604 | |
| 3605 | // The plan behind the slider — consumed by the fader's onEleven to pick the |
| 3606 | // free create or the Stripe handoff. tier "" = free, which is every create |
| 3607 | // that didn't bounce off a paid gate. |
| 3608 | let planChoice = null; |
| 3609 | let planTier = "free"; |
| 3610 | // planPaidRequired: the create bounced off a gate paying genuinely fixes |
| 3611 | // (the free quota, or the pool under budget pressure) — the plan screen |
| 3612 | // re-opens showing the paid tiers. The HAPPY path never shows them: every |
| 3613 | // space is born free, the slider creates it immediately, and "who is this |
| 3614 | // space for?" is Eleven's question inside the space (#1374). |
| 3615 | let planPaidRequired = false; |
| 3616 | |
| 3617 | // openCreate opens the create: the slider, and nothing before it. Nothing is |
| 3618 | // asked on the way in — not a name (Eleven asks inside the space), not a plan |
| 3619 | // (every space is born free, #1374) — so the fastest path from "I want a |
| 3620 | // space" to having one is a single gesture. The plan screen exists only for |
| 3621 | // the bounce (openPlanBounce). |
| 3622 | function openCreate() { |
| 3623 | clearUndo(); |
| 3624 | planChoice = null; |
| 3625 | planTier = "free"; |
| 3626 | planPaidRequired = false; |
| 3627 | planStatus(""); |
| 3628 | // The ledger read warms the price book — the paid-required error path needs |
| 3629 | // it in hand (renderPlanTiers fills in when it answers). |
| 3630 | if (spacesEnabled && provisioned === null && state.token) refreshProvisioned(); |
| 3631 | hideVault(); |
| 3632 | openCreateFader(); |
| 3633 | } |
| 3634 | |
| 3635 | function closePlan() { |
| 3636 | $("create-plan").classList.add("hidden"); |
| 3637 | showVault(); |
| 3638 | } |
| 3639 | |
| 3640 | // openPlanBounce shows the plan screen — the ONLY thing that does. The create |
| 3641 | // hit a gate paying genuinely fixes (the free quota, the pool under budget |
| 3642 | // pressure), so the paid tiers are the way through, with the gate's own |
| 3643 | // message above them. |
| 3644 | function openPlanBounce(message) { |
| 3645 | fader?.stop(); |
| 3646 | $("create").classList.add("hidden"); |
| 3647 | planPaidRequired = true; |
| 3648 | planTier = billingTiers[0].key; |
| 3649 | renderPlanTiers(); |
| 3650 | $("create-plan").classList.remove("hidden"); |
| 3651 | planStatus(message, "error"); |
| 3652 | } |
| 3653 | |
| 3654 | function planStatus(msg, kind) { |
| 3655 | const el = $("plan-status"); |
| 3656 | el.textContent = msg || ""; |
| 3657 | el.className = "status" + (kind ? " " + kind : ""); |
| 3658 | } |
| 3659 | |
| 3660 | // renderPlanTiers paints the who-pays picker from the provisioner's price |
| 3661 | // book — but ONLY in paid-required mode (#1374): the happy path asks nothing |
| 3662 | // at all, and the plan question lives in Eleven's setup conversation. Here the |
| 3663 | // create bounced off the quota or the pool gate, so the paid tiers (no Free |
| 3664 | // card — free is exactly what bounced) are the way through, at base price |
| 3665 | // with the ≈ local figure the upgrade picker also shows. |
| 3666 | function renderPlanTiers() { |
| 3667 | const wrap = $("plan-tiers-wrap"); |
| 3668 | const showing = planPaidRequired && billingTiers.length; |
| 3669 | wrap.classList.toggle("hidden", !showing); |
| 3670 | if (!showing) return; |
| 3671 | $("plan-tiers-lead").textContent = "Pick a plan to create it now:"; |
| 3672 | // The ≈ rates ride the same one-fetch-per-page-life snapshot as the |
| 3673 | // upgrade picker; render now, again when they land. |
| 3674 | if (!fxSnapshot) { |
| 3675 | fxSnapshot = { date: "", rates: null }; |
| 3676 | apiJSON("/api/fx") |
| 3677 | .then((b) => { |
| 3678 | fxSnapshot = { date: b.date || "", rates: b.rates || null }; |
| 3679 | if (!$("create-plan").classList.contains("hidden")) renderPlanTiers(); |
| 3680 | }) |
| 3681 | .catch(() => { /* no rates — the EUR stickers stand alone */ }); |
| 3682 | } |
| 3683 | const currency = approxCurrency || regionCurrency(navigator.language); |
| 3684 | const rates = fxSnapshot && fxSnapshot.rates; |
| 3685 | const box = $("plan-tiers"); |
| 3686 | box.textContent = ""; |
| 3687 | const card = (key, title, priceLine, subLine, approx) => { |
| 3688 | const btn = document.createElement("button"); |
| 3689 | btn.type = "button"; |
| 3690 | btn.className = "tier-btn wrap" + (key === planTier ? " selected" : ""); |
| 3691 | btn.setAttribute("aria-pressed", key === planTier ? "true" : "false"); |
| 3692 | const name = document.createElement("span"); |
| 3693 | name.className = "tier-name"; |
| 3694 | name.textContent = title; |
| 3695 | const price = document.createElement("span"); |
| 3696 | price.className = "tier-price"; |
| 3697 | price.textContent = priceLine; |
| 3698 | btn.append(name, price); |
| 3699 | if (approx) { |
| 3700 | const a = document.createElement("span"); |
| 3701 | a.className = "tier-approx"; |
| 3702 | a.textContent = approx; |
| 3703 | btn.append(a); |
| 3704 | } |
| 3705 | if (subLine) { |
| 3706 | const sub = document.createElement("span"); |
| 3707 | sub.className = "tier-sub"; |
| 3708 | sub.textContent = subLine; |
| 3709 | btn.append(sub); |
| 3710 | } |
| 3711 | btn.addEventListener("click", () => { |
| 3712 | planTier = key; |
| 3713 | renderPlanTiers(); |
| 3714 | }); |
| 3715 | box.append(btn); |
| 3716 | }; |
| 3717 | for (const t of billingTiers) { |
| 3718 | const view = tierForBand(t, "base"); |
| 3719 | const approx = currency === "EUR" ? "" : approxLocal(view.eur, currency, rates, navigator.language); |
| 3720 | card(t.key, t.name, view.label + (t.business ? " excl. VAT" : ""), "", approx); |
| 3721 | } |
| 3722 | const fxNote = $("plan-fx"); |
| 3723 | fxNote.classList.toggle("hidden", !(rates && currency && currency !== "EUR")); |
| 3724 | fxNote.textContent = rates && fxSnapshot.date ? "Billed in euros — ≈ at the ECB rate of " + fxSnapshot.date + "." : ""; |
| 3725 | } |
| 3726 | |
| 3727 | // planContinue commits the bounce's chosen plan and returns to the slider — |
| 3728 | // the same commit as the free path, with a Stripe handoff behind it. |
| 3729 | function planContinue() { |
| 3730 | planChoice = { tier: planTier === "free" ? "" : planTier }; |
| 3731 | $("create-plan").classList.add("hidden"); |
| 3732 | openCreateFader(); |
| 3733 | } |
| 3734 | |
| 3735 | // openCreateFader opens step 2, the goes-to-eleven slider — the commit for |
| 3736 | // both branches; only the promise behind it differs. |
| 3737 | function openCreateFader() { |
| 3738 | $("new-space-result").classList.add("hidden"); |
| 3739 | $("create-back").classList.remove("hidden"); |
| 3740 | $("create-sub-line").textContent = planChoice && planChoice.tier |
| 3741 | ? "Slide, and we'll take you to the payment — your space is created the moment it's paid." |
| 3742 | : "Your new space will be created immediately."; |
| 3743 | spaceStatus(""); |
| 3744 | $("create").classList.remove("hidden"); |
| 3745 | const f = ensureFader(); |
| 3746 | f.reset(); |
| 3747 | f.start(); |
| 3748 | } |
| 3749 | |
| 3750 | function closeCreate() { |
| 3751 | fader?.stop(); |
| 3752 | $("create").classList.add("hidden"); |
| 3753 | showVault(); // reveals the list again, now including any newly created space |
| 3754 | } |
| 3755 | |
| 3756 | // ---- archive: list, unarchive, permanent delete -------------------------------- |
| 3757 | |
| 3758 | // archiving is the entry currently in the stern delete step of the archive. |
| 3759 | let archiving = null; |
| 3760 | // The last-admin guard: before letting someone drop the only saved copy of a |
| 3761 | // space's ONLY admin key, ask the space itself (checkSoleAdmin). While |
| 3762 | // sternSoleAdmin is set, "Delete your access" stays off; sternDestroyable |
| 3763 | // (provisioner ownership confirmed) picks which way the note points instead. |
| 3764 | let sternSoleAdmin = false; |
| 3765 | let sternDestroyable = false; |
| 3766 | // sternShell: the provisioner's ledger says this owned space was never set up |
| 3767 | // and holds only its pre-seeded admin — an unusable shell nobody can enter. |
| 3768 | // Deleting access to one strands a quota slot forever, so destroy is promoted |
| 3769 | // to the only action. Server truth (the meter sweep records claimed-ness), so |
| 3770 | // it works exactly where checkSoleAdmin can't: entries with no credentials. |
| 3771 | let sternShell = false; |
| 3772 | // sternPaid: the ledger says this space has a live subscription — destroying |
| 3773 | // it also cancels that subscription (the provisioner enforces it), and the |
| 3774 | // ceremony copy must say so. |
| 3775 | let sternPaid = false; |
| 3776 | |
| 3777 | // openArchive shows the archive screen (the archived-spaces list). |
| 3778 | function openArchive() { |
| 3779 | clearUndo(); |
| 3780 | archiving = null; |
| 3781 | hideVault(); |
| 3782 | $("archive-stern").classList.add("hidden"); |
| 3783 | $("archive-checking").classList.add("hidden"); |
| 3784 | $("archive-main").classList.remove("hidden"); |
| 3785 | $("archive").classList.remove("hidden"); |
| 3786 | archiveStatus(""); |
| 3787 | renderArchiveList(); |
| 3788 | } |
| 3789 | |
| 3790 | function closeArchive() { |
| 3791 | archiving = null; |
| 3792 | $("archive").classList.add("hidden"); |
| 3793 | showVault(); |
| 3794 | } |
| 3795 | |
| 3796 | // closeArchiveStern steps back from the delete step to the archive list. |
| 3797 | function closeArchiveStern() { |
| 3798 | archiving = null; |
| 3799 | $("archive-stern").classList.add("hidden"); |
| 3800 | $("archive-checking").classList.add("hidden"); |
| 3801 | $("archive-main").classList.remove("hidden"); |
| 3802 | archiveStatus(""); |
| 3803 | } |
| 3804 | |
| 3805 | function renderArchiveList() { |
| 3806 | const box = $("archive-list"); |
| 3807 | box.textContent = ""; |
| 3808 | const { archived } = partitionArchived(vault.obj.servers || []); |
| 3809 | if (!archived.length) { |
| 3810 | box.append(emptyLine("Nothing archived.")); |
| 3811 | return; |
| 3812 | } |
| 3813 | const ul = document.createElement("ul"); |
| 3814 | ul.className = "servers archived"; |
| 3815 | for (const s of archived) ul.append(archiveRow(s)); |
| 3816 | box.append(ul); |
| 3817 | } |
| 3818 | |
| 3819 | function archiveRow(s) { |
| 3820 | const li = document.createElement("li"); |
| 3821 | li.dataset.url = s.url; |
| 3822 | |
| 3823 | const main = document.createElement("div"); |
| 3824 | main.className = "srv-main"; |
| 3825 | const name = document.createElement("span"); // plain text — archived spaces stay quiet |
| 3826 | name.className = "name"; |
| 3827 | name.textContent = labelOf(s); |
| 3828 | if (s.name) name.title = hostOf(s.url); |
| 3829 | main.append(name); |
| 3830 | |
| 3831 | const unarch = document.createElement("button"); |
| 3832 | unarch.type = "button"; |
| 3833 | unarch.className = "pill"; |
| 3834 | unarch.textContent = "Unarchive"; |
| 3835 | unarch.addEventListener("click", () => unarchiveSpace(s)); |
| 3836 | |
| 3837 | const del = document.createElement("button"); |
| 3838 | del.type = "button"; |
| 3839 | del.className = "pill danger-ink"; |
| 3840 | del.textContent = "Delete…"; |
| 3841 | del.addEventListener("click", () => openArchiveDelete(s)); |
| 3842 | |
| 3843 | li.append(main, unarch, del); |
| 3844 | return li; |
| 3845 | } |
| 3846 | |
| 3847 | // unarchiveSpace moves the entry back to the active list. |
| 3848 | function unarchiveSpace(s) { |
| 3849 | vault.obj.servers = setArchived(vault.obj.servers, s.url, false); |
| 3850 | if (!partitionArchived(vault.obj.servers).archived.length) { |
| 3851 | closeArchive(); // archive is empty now — the space is back on the list |
| 3852 | persistVault(); |
| 3853 | return; |
| 3854 | } |
| 3855 | renderArchiveList(); |
| 3856 | persistVault(archiveStatus); |
| 3857 | } |
| 3858 | |
| 3859 | // The stern step assembles BEFORE it shows: both checks (ownership → destroy |
| 3860 | // zone, last-admin → hold) run first, so pieces never pop into a visible panel. |
| 3861 | // A fast answer shows no loader at all; past STERN_LOADER_DELAY_MS the spinner |
| 3862 | // appears, and at STERN_CHECK_CAP_MS we reveal with whatever has settled — a |
| 3863 | // straggler still applies live (rare, and correctness beats stillness). |
| 3864 | const STERN_LOADER_DELAY_MS = 150; |
| 3865 | const STERN_CHECK_CAP_MS = 2500; |
| 3866 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); |
| 3867 | |
| 3868 | // openArchiveDelete opens the stern permanent-delete step for one archived space. |
| 3869 | // "Delete your access" (drop the saved entry) is offered unless the space reports |
| 3870 | // this is its ONLY admin key (checkSoleAdmin); for a space THIS account created, |
| 3871 | // the deeper "destroy for everyone" is revealed too (ownership checked against |
| 3872 | // the provisioner ledger; the server re-enforces it on delete). |
| 3873 | async function openArchiveDelete(s) { |
| 3874 | archiving = s; |
| 3875 | sternSoleAdmin = false; |
| 3876 | sternDestroyable = false; |
| 3877 | sternShell = false; |
| 3878 | sternPaid = false; |
| 3879 | const host = hostOf(s.url); |
| 3880 | $("stern-name").textContent = labelOf(s); |
| 3881 | $("stern-host").textContent = host; |
| 3882 | $("delete-arm").value = ""; |
| 3883 | $("delete-arm").placeholder = host; |
| 3884 | $("delete-access").disabled = true; |
| 3885 | $("sole-admin-note").classList.add("hidden"); |
| 3886 | $("destroy-zone").classList.add("hidden"); |
| 3887 | $("destroy-forever").disabled = true; |
| 3888 | $("archive-main").classList.add("hidden"); |
| 3889 | $("archive-stern").classList.add("hidden"); |
| 3890 | $("archive-checking").classList.add("hidden"); |
| 3891 | archiveStatus(""); |
| 3892 | |
| 3893 | const checks = Promise.allSettled([checkOwnership(s, host), checkSoleAdmin(s)]); |
| 3894 | const fast = await Promise.race([checks.then(() => true), sleep(STERN_LOADER_DELAY_MS).then(() => false)]); |
| 3895 | if (archiving !== s) return; // backed out mid-check |
| 3896 | if (!fast) { |
| 3897 | $("archive-checking").classList.remove("hidden"); |
| 3898 | await Promise.race([checks, sleep(STERN_CHECK_CAP_MS)]); |
| 3899 | if (archiving !== s) return; |
| 3900 | $("archive-checking").classList.add("hidden"); |
| 3901 | } |
| 3902 | updateSternHold(); |
| 3903 | $("archive-stern").classList.remove("hidden"); |
| 3904 | } |
| 3905 | |
| 3906 | // checkOwnership learns whether THIS account created the space (the provisioner |
| 3907 | // ledger), which puts "destroy for everyone" on the table. Rendering happens in |
| 3908 | // updateSternHold — this only sets the flag, so it composes with the pre-reveal |
| 3909 | // assembly above and still applies live if it straggles past the cap. |
| 3910 | function checkOwnership(s, host) { |
| 3911 | if (!spacesEnabled) return Promise.resolve(); |
| 3912 | return apiJSON("/api/spaces", { headers: { Authorization: "Bearer " + state.token } }) |
| 3913 | .then((r) => { |
| 3914 | const row = (r.spaces || []).find((sp) => sp.host === host); |
| 3915 | if (row && archiving === s) { |
| 3916 | sternDestroyable = true; |
| 3917 | sternPaid = row.entitlement === "paid"; |
| 3918 | // An owned space the ledger has SEEN (a sweep ran) but that was never |
| 3919 | // set up and never joined is an unusable shell — and when the entry |
| 3920 | // carries no credentials (never enrolled), no live check could ever |
| 3921 | // say otherwise. Promote destroy; deleting access would only strand |
| 3922 | // the quota slot. |
| 3923 | const token = s.credentials && s.credentials.token; |
| 3924 | sternShell = !token && !row.setup_complete && (row.members || 1) <= 1 && !!row.metered_at; |
| 3925 | prefetchLogin(); // the destroy ceremony needs fresh options (Safari gesture rule) |
| 3926 | updateSternHold(); |
| 3927 | } |
| 3928 | }) |
| 3929 | .catch(() => { /* can't confirm ownership — don't offer the destroy */ }); |
| 3930 | } |
| 3931 | |
| 3932 | // checkSoleAdmin asks the space itself — with the saved session token, straight |
| 3933 | // from the browser so this server stays blind to which spaces the user holds — |
| 3934 | // whether this key is its ONLY admin key (instances answer CORS for exactly this |
| 3935 | // origin on /api/me). Fails OPEN: an unreachable space or an older build without |
| 3936 | // the field must never make its entry undeletable, so the hold only ever engages |
| 3937 | // on a positive answer. |
| 3938 | function checkSoleAdmin(s) { |
| 3939 | const token = s.credentials && s.credentials.token; |
| 3940 | if (!token) return Promise.resolve(); |
| 3941 | let url; |
| 3942 | try { url = new URL("/api/me?token=" + encodeURIComponent(token), s.url); } catch { return Promise.resolve(); } |
| 3943 | return fetch(url, { signal: AbortSignal.timeout(8000) }) |
| 3944 | .then((r) => (r.ok ? r.json() : null)) |
| 3945 | .then((me) => { |
| 3946 | if (soleAdminHold(me) && archiving === s) { |
| 3947 | sternSoleAdmin = true; |
| 3948 | updateSternHold(); |
| 3949 | } |
| 3950 | }) |
| 3951 | .catch(() => { /* no answer — no hold */ }); |
| 3952 | } |
| 3953 | |
| 3954 | // updateSternHold renders what the checks learned: the destroy zone when this |
| 3955 | // account owns the space, and the last-admin hold — "Delete your access" goes |
| 3956 | // (and stays) off. When BOTH hold (last admin of a space this account created), |
| 3957 | // destroying is the only real action left, so it isn't offered as a footnote |
| 3958 | // link under a dead button: the whole step flips to the destroy framing — |
| 3959 | // title, copy, and the destroy button promoted to the primary slot. Rendering |
| 3960 | // is declarative from the two flags, so straggling checks re-render cleanly. |
| 3961 | function updateSternHold() { |
| 3962 | const destroyOnly = (sternSoleAdmin || sternShell) && sternDestroyable; |
| 3963 | $("stern-title").textContent = destroyOnly ? "Destroy this space?" : "Delete your access?"; |
| 3964 | $("stern-intro").classList.toggle("hidden", destroyOnly); |
| 3965 | $("delete-access").classList.toggle("hidden", destroyOnly); |
| 3966 | $("destroy-zone").classList.toggle("hidden", !sternDestroyable); |
| 3967 | $("destroy-zone").classList.toggle("primary", destroyOnly); |
| 3968 | $("destroy-note").classList.toggle("hidden", destroyOnly); |
| 3969 | const destroy = $("destroy-forever"); |
| 3970 | destroy.className = destroyOnly ? "btn-danger" : "linkish danger-link"; |
| 3971 | destroy.textContent = destroyOnly ? "Destroy the space for everyone" : "Destroy the space for everyone…"; |
| 3972 | $("destroy-paid").classList.toggle("hidden", !sternPaid); |
| 3973 | const note = $("sole-admin-note"); |
| 3974 | // The subscription line rides every destroy framing for a paid space — the |
| 3975 | // one thing a payer needs to hear before the button. |
| 3976 | const paidLine = sternPaid ? ["Its subscription is cancelled with it — no further charges. "] : []; |
| 3977 | if (!sternSoleAdmin && !sternShell) { note.classList.add("hidden"); return; } |
| 3978 | if (sternShell) { |
| 3979 | const host = Object.assign(document.createElement("b"), { textContent: $("stern-host").textContent }); |
| 3980 | const noUndo = Object.assign(document.createElement("b"), { textContent: "This cannot be undone." }); |
| 3981 | note.replaceChildren( |
| 3982 | host, " was never set up — it has no members and no way in, but it still ", |
| 3983 | "counts toward your space limit. Destroying it frees that slot. ", ...paidLine, noUndo, |
| 3984 | ); |
| 3985 | } else if (destroyOnly) { |
| 3986 | const host = Object.assign(document.createElement("b"), { textContent: $("stern-host").textContent }); |
| 3987 | const noUndo = Object.assign(document.createElement("b"), { textContent: "This cannot be undone." }); |
| 3988 | note.replaceChildren( |
| 3989 | "This is the only admin key for ", host, " — deleting just your access would ", |
| 3990 | "leave the space running with no admin, forever, and still counting toward ", |
| 3991 | "your space limit. You created it, so you can destroy the space for everyone ", |
| 3992 | "instead, which frees that slot: every message, photo, and member in it. ", |
| 3993 | ...paidLine, noUndo, |
| 3994 | ); |
| 3995 | } else { |
| 3996 | note.textContent = |
| 3997 | "This is the space's only admin key. Deleting it would leave the space " + |
| 3998 | "running with no admin, forever. Make another member an admin first, then " + |
| 3999 | "come back to delete your access."; |
| 4000 | } |
| 4001 | note.classList.remove("hidden"); |
| 4002 | $("delete-access").disabled = true; |
| 4003 | } |
| 4004 | |
| 4005 | // After a delete/destroy empties the entry, return to wherever makes sense: the |
| 4006 | // archive list if anything's left, else the main list. |
| 4007 | function afterDelete(okMsg) { |
| 4008 | archiving = null; |
| 4009 | if (partitionArchived(vault.obj.servers).archived.length) { |
| 4010 | closeArchiveStern(); |
| 4011 | renderArchiveList(); |
| 4012 | archiveStatus(okMsg, "ok"); |
| 4013 | } else { |
| 4014 | closeArchive(); |
| 4015 | vaultStatus(okMsg, "ok"); |
| 4016 | } |
| 4017 | } |
| 4018 | |
| 4019 | // deleteAccess drops the saved entry from the vault — permanently. The space |
| 4020 | // keeps running for everyone else; this only removes THIS account's saved way |
| 4021 | // back in (the strongly-worded warning makes that consequence explicit). |
| 4022 | async function deleteAccess() { |
| 4023 | if (!archiving || sternSoleAdmin) return; // the last-admin hold — see updateSternHold |
| 4024 | const s = archiving; |
| 4025 | vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== s.url); |
| 4026 | // The tombstone makes the removal STICK: signed-in devices drop their copy |
| 4027 | // and stop re-pushing it, instead of resurrecting the entry on next sync. |
| 4028 | vault.obj.removed = addTombstone(vault.obj.removed, s.url, Date.now()); |
| 4029 | archiveStatus("Deleting…", "busy"); |
| 4030 | try { |
| 4031 | await saveVault(); |
| 4032 | afterDelete("Removed from your account."); |
| 4033 | } catch (e) { |
| 4034 | archiveStatus(e.message || "Couldn't save the change.", "error"); |
| 4035 | } |
| 4036 | } |
| 4037 | |
| 4038 | // destroyForEveryone is the owner-only deeper action: a FRESH passkey ceremony |
| 4039 | // (a link token alone can never destroy a space) then DELETE against the |
| 4040 | // provisioner, which re-checks ownership against its ledger. |
| 4041 | async function destroyForEveryone() { |
| 4042 | if (!archiving) return; |
| 4043 | const s = archiving; |
| 4044 | const host = $("stern-host").textContent; |
| 4045 | const btn = $("destroy-forever"); |
| 4046 | btn.disabled = true; |
| 4047 | archiveStatus("Confirm it’s you…", "busy"); |
| 4048 | let proof; |
| 4049 | try { |
| 4050 | ({ proof } = await runLogin(loginBegin)); |
| 4051 | noteProof(proof); // fills the confirm-step pocket (Sign-in options) |
| 4052 | } catch (e) { |
| 4053 | prefetchLogin(); |
| 4054 | archiveStatus(friendlyCeremonyError(e, "signin").message, "error"); |
| 4055 | btn.disabled = false; |
| 4056 | return; |
| 4057 | } |
| 4058 | archiveStatus("Destroying the space…", "busy"); |
| 4059 | try { |
| 4060 | const res = await fetch("/api/spaces/" + encodeURIComponent(host), { |
| 4061 | method: "DELETE", |
| 4062 | headers: { "X-Ceremony-Proof": proof }, |
| 4063 | }); |
| 4064 | const body = await res.json().catch(() => ({})); |
| 4065 | // 404 = not in the ledger (already gone / not yours) — treat "already gone" |
| 4066 | // as done; the list entry goes either way. |
| 4067 | if (!res.ok && res.status !== 404) { |
| 4068 | throw new Error(body.message || body.error || `Couldn't delete (HTTP ${res.status}).`); |
| 4069 | } |
| 4070 | vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== s.url); |
| 4071 | vault.obj.removed = addTombstone(vault.obj.removed, s.url, Date.now()); |
| 4072 | try { await saveVault(); } catch { /* the space is gone; the entry retries next save */ } |
| 4073 | afterDelete("Space destroyed."); |
| 4074 | } catch (e) { |
| 4075 | archiveStatus(e.message || String(e), "error"); |
| 4076 | btn.disabled = false; |
| 4077 | } |
| 4078 | } |
| 4079 | |
| 4080 | // ---- wiring ------------------------------------------------------------------- |
| 4081 | |
| 4082 | const onSignInError = (err) => { |
| 4083 | // A friendly, specific line for what went wrong (ceremony-errors.js), with |
| 4084 | // the technical crumb parenthesised — it might just be the wrong passkey. |
| 4085 | // ANY failure or cancel reveals #newhere, the one place the create path |
| 4086 | // lives now: a cancelled prompt and a missing credential are deliberately |
| 4087 | // indistinguishable (NotAllowedError either way), so the explainer serves |
| 4088 | // both readings and only then offers "Create your passkey". It shows on |
| 4089 | // whichever surface ran the ceremony (main card or all-ways). |
| 4090 | setStatus(formatCeremonyError(err, "signin"), "error"); |
| 4091 | revealNewhere(); |
| 4092 | prefetchRegister(); // ready the create ceremony in case they're new |
| 4093 | setSignedOutBusy(false); |
| 4094 | prefetchLogin(); // options were spent — refresh for a retry |
| 4095 | }; |
| 4096 | |
| 4097 | const onCreatePasskeyError = (err) => { |
| 4098 | setStatus(formatCeremonyError(err, "create"), "error"); |
| 4099 | setSignedOutBusy(false); |
| 4100 | }; |
| 4101 | |
| 4102 | // Arriving from a demo banner's "Keep this space" while signed out: say why |
| 4103 | // you're looking at a sign-in page. The fragment waits; the claim screen |
| 4104 | // opens on the first vault render after sign-in (or after creating one). |
| 4105 | if (claimLinkHost && !(state.token && state.dek)) { |
| 4106 | setStatus("Sign in to keep your demo space.", ""); |
| 4107 | } |
| 4108 | |
| 4109 | // Arriving from a join page's sign-in-first card while signed out: say where |
| 4110 | // this leads, host included (the display half of the redirect guard). |
| 4111 | if (joinReturnURL() && !(state.token && state.dek)) { |
| 4112 | setStatus("Sign in, and you'll head straight back to " + hostOf(joinReturnURL()) + " to finish joining.", ""); |
| 4113 | } |
| 4114 | |
| 4115 | if (window.PublicKeyCredential) { |
| 4116 | // The passkey controls get pre-fetched ceremony options, so a click reaches |
| 4117 | // navigator.credentials.* inside the gesture (Safari activation). A browser |
| 4118 | // without WebAuthn keeps those controls off (setSignedOutBusy) — the Apple |
| 4119 | // and email doors still work. |
| 4120 | prefetchLogin(); |
| 4121 | prefetchRegister(); |
| 4122 | } |
| 4123 | composeCard(); // the #1112 card: one primary door, email beneath, the other-ways link |
| 4124 | setSignedOutBusy(false); // paints the no-WebAuthn disable onto the passkey controls |
| 4125 | $("signin").addEventListener("click", () => signIn().catch(onSignInError)); |
| 4126 | $("create-passkey").addEventListener("click", () => createPasskey().catch(onCreatePasskeyError)); |
| 4127 | $("unlock-passkey").addEventListener("click", () => signIn().catch(onSignInError)); |
| 4128 | $("apple-signin").addEventListener("click", startAppleSignIn); |
| 4129 | $("google-signin").addEventListener("click", (e) => startOidcSignIn("google", e)); |
| 4130 | $("microsoft-signin").addEventListener("click", (e) => startOidcSignIn("microsoft", e)); |
| 4131 | $("github-signin").addEventListener("click", (e) => startOidcSignIn("github", e)); |
| 4132 | // The instant the Apple confirmation proceeds, this page navigates away — |
| 4133 | // the waiting face covers whatever frames remain (bfcache return releases it). |
| 4134 | window.addEventListener("pagehide", engageSiwaCover); |
| 4135 | // Coming BACK from Apple (bfcache restore) must not strand a spinning button. |
| 4136 | window.addEventListener("pageshow", (e) => { |
| 4137 | if (!e.persisted) return; |
| 4138 | releaseApplePending(); |
| 4139 | }); |
| 4140 | $("cookie-ask").addEventListener("click", () => $("cookie-dialog").showModal()); |
| 4141 | $("cookie-close").addEventListener("click", () => $("cookie-dialog").close()); |
| 4142 | $("cookie-dialog").addEventListener("click", (e) => { |
| 4143 | if (e.target === $("cookie-dialog")) $("cookie-dialog").close(); // backdrop tap |
| 4144 | }); |
| 4145 | $("email-signin").addEventListener("click", openEmailDoor); |
| 4146 | $("email-back").addEventListener("click", closeEmailDoor); |
| 4147 | $("email-form").addEventListener("submit", sendEmailCode); |
| 4148 | $("email-code-form").addEventListener("submit", verifyEmailCode); |
| 4149 | // The all-ways step: each door invokes the SAME handler as the main card's |
| 4150 | // version — the step is another surface, never another code path. |
| 4151 | $("other-ways").addEventListener("click", openOtherWays); |
| 4152 | $("otherways-back").addEventListener("click", closeOtherWays); |
| 4153 | $("otherways-apple").addEventListener("click", startAppleSignIn); |
| 4154 | $("otherways-google").addEventListener("click", (e) => startOidcSignIn("google", e)); |
| 4155 | $("otherways-microsoft").addEventListener("click", (e) => startOidcSignIn("microsoft", e)); |
| 4156 | $("otherways-github").addEventListener("click", (e) => startOidcSignIn("github", e)); |
| 4157 | $("combine-oidc-go").addEventListener("click", (e) => { |
| 4158 | if (combine && OIDC_WEB[combine.kind]) startOidcSignIn(combine.kind, e, { intent: "merge-" + combine.kind, select: true }); |
| 4159 | }); |
| 4160 | $("otherways-email").addEventListener("click", openEmailDoor); |
| 4161 | $("otherways-passkey").addEventListener("click", () => signIn().catch(onSignInError)); |
| 4162 | |
| 4163 | // The SiwA web round trip lands back here as #siwa=<id_token>&state=web.<r> |
| 4164 | // (siwa.go's trampoline — the fragment never reaches the server). The state |
| 4165 | // must match the one this tab minted before leaving for Apple; the hash is |
| 4166 | // cleared either way, so a reload can't replay the token. |
| 4167 | let siwaBooting = false; // claims the boot — the cold-open path below stands down |
| 4168 | { |
| 4169 | const m = location.hash.match(/^#siwa=([^&]*)&state=([^&]*)$/); |
| 4170 | if (m) { |
| 4171 | siwaBooting = true; |
| 4172 | // The pre-paint gate already put the waiting face up (html.siwa-wait) — |
| 4173 | // the clean card never shows. |
| 4174 | history.replaceState(null, "", location.pathname + location.search); |
| 4175 | const minted = sessionStorage.getItem("home_siwa_state") || ""; |
| 4176 | sessionStorage.removeItem("home_siwa_state"); |
| 4177 | // The methods step's round-trip markers (add-Apple's stashed proof, the |
| 4178 | // confirm-with-Apple return flag, the serialized in-progress intent) — |
| 4179 | // consumed exactly once, here, so a reload can't replay any of them. |
| 4180 | const linkMark = sessionStorage.getItem("home_siwa_link"); |
| 4181 | const methodsReturn = !!sessionStorage.getItem("home_methods_return"); |
| 4182 | const methodsIntent = sessionStorage.getItem("home_methods_intent") || ""; |
| 4183 | sessionStorage.removeItem("home_siwa_link"); |
| 4184 | sessionStorage.removeItem("home_methods_return"); |
| 4185 | sessionStorage.removeItem("home_methods_intent"); |
| 4186 | if (minted && decodeURIComponent(m[2]) === minted) { |
| 4187 | if (linkMark) finishSiwaLink(decodeURIComponent(m[1]), linkMark); |
| 4188 | else if (methodsIntent === "merge-apple") finishSiwaMergeProve(decodeURIComponent(m[1])); |
| 4189 | else finishSiwa(decodeURIComponent(m[1]), methodsReturn, methodsIntent); |
| 4190 | } else { |
| 4191 | // A state that didn't start here: straight to the signed-out card. |
| 4192 | finishHomeLoader({ immediate: true }); |
| 4193 | setStatus("That Apple sign-in didn’t start here — try again.", "error"); |
| 4194 | } |
| 4195 | } |
| 4196 | } |
| 4197 | |
| 4198 | // The OIDC web round trips (Google today) land as #oidc=<id_token>&state= |
| 4199 | // (oidc.go's provider-blind trampoline). Which door was opened — and the |
| 4200 | // nonce the verifier must see round-trip — rides home_oidc_state, minted |
| 4201 | // beside the state before departure. Same one-shot marker consumption as |
| 4202 | // the #siwa= leg above. |
| 4203 | let oidcBooting = false; // claims the boot — the cold-open path below stands down |
| 4204 | if (!siwaBooting) { |
| 4205 | const m = location.hash.match(/^#oidc=([^&]*)&state=([^&]*)$/); |
| 4206 | if (m) { |
| 4207 | oidcBooting = true; |
| 4208 | history.replaceState(null, "", location.pathname + location.search); |
| 4209 | let rec = null; |
| 4210 | try { rec = JSON.parse(sessionStorage.getItem("home_oidc_state") || "null"); } catch { /* mangled */ } |
| 4211 | sessionStorage.removeItem("home_oidc_state"); |
| 4212 | const linkMark = sessionStorage.getItem("home_siwa_link"); |
| 4213 | const methodsReturn = !!sessionStorage.getItem("home_methods_return"); |
| 4214 | const methodsIntent = sessionStorage.getItem("home_methods_intent") || ""; |
| 4215 | sessionStorage.removeItem("home_siwa_link"); |
| 4216 | sessionStorage.removeItem("home_methods_return"); |
| 4217 | sessionStorage.removeItem("home_methods_intent"); |
| 4218 | if (rec && rec.state && OIDC_WEB[rec.provider] && decodeURIComponent(m[2]) === rec.state) { |
| 4219 | if (linkMark) finishOidcLink(rec, decodeURIComponent(m[1]), linkMark); |
| 4220 | else if (methodsIntent === "merge-" + rec.provider) finishOidcMergeProve(rec, decodeURIComponent(m[1])); |
| 4221 | else finishOidc(rec, decodeURIComponent(m[1]), methodsReturn, methodsIntent); |
| 4222 | } else { |
| 4223 | finishHomeLoader({ immediate: true }); |
| 4224 | setStatus("That sign-in didn’t start here — try again.", "error"); |
| 4225 | } |
| 4226 | } |
| 4227 | } |
| 4228 | |
| 4229 | // The code-flow return leg (GitHub) lands as #oauth=<code>&state=web.<r> |
| 4230 | // (github.go's GET trampoline). Same one-shot consumption; the code is |
| 4231 | // single-use and worthless without the server-held secret either way. |
| 4232 | let oauthBooting = false; // claims the boot — the cold-open path below stands down |
| 4233 | if (!siwaBooting && !oidcBooting) { |
| 4234 | const m = location.hash.match(/^#oauth=([^&]*)&state=([^&]*)$/); |
| 4235 | if (m) { |
| 4236 | oauthBooting = true; |
| 4237 | history.replaceState(null, "", location.pathname + location.search); |
| 4238 | let rec = null; |
| 4239 | try { rec = JSON.parse(sessionStorage.getItem("home_oidc_state") || "null"); } catch { /* mangled */ } |
| 4240 | sessionStorage.removeItem("home_oidc_state"); |
| 4241 | const linkMark = sessionStorage.getItem("home_siwa_link"); |
| 4242 | const methodsReturn = !!sessionStorage.getItem("home_methods_return"); |
| 4243 | const methodsIntent = sessionStorage.getItem("home_methods_intent") || ""; |
| 4244 | sessionStorage.removeItem("home_siwa_link"); |
| 4245 | sessionStorage.removeItem("home_methods_return"); |
| 4246 | sessionStorage.removeItem("home_methods_intent"); |
| 4247 | if (rec && rec.state && OIDC_WEB[rec.provider] && decodeURIComponent(m[2]) === rec.state) { |
| 4248 | if (linkMark) finishOidcLink(rec, decodeURIComponent(m[1]), linkMark); |
| 4249 | else if (methodsIntent === "merge-" + rec.provider) finishOidcMergeProve(rec, decodeURIComponent(m[1])); |
| 4250 | else finishOidc(rec, decodeURIComponent(m[1]), methodsReturn, methodsIntent); |
| 4251 | } else { |
| 4252 | finishHomeLoader({ immediate: true }); |
| 4253 | setStatus("That sign-in didn’t start here — try again.", "error"); |
| 4254 | } |
| 4255 | } |
| 4256 | } |
| 4257 | |
| 4258 | // The mail's magic link (email.go) lands here as #email=<address>&code=<6 |
| 4259 | // digits> — the same values the mail asks you to type, riding the fragment, |
| 4260 | // which never reaches any server. It runs the ordinary /api/email/verify, so |
| 4261 | // the code's single-use/expiry/attempt machinery is the whole story — and it |
| 4262 | // works in any browser, recovery-grade, exactly like typing the code. The |
| 4263 | // hash clears before the verify, so a reload can't rerun the (single-use |
| 4264 | // anyway) code. |
| 4265 | let emailLinkBooting = false; // claims the boot — the cold-open path below stands down |
| 4266 | if (!siwaBooting && !oidcBooting && !oauthBooting && location.hash.indexOf("#email=") === 0) { |
| 4267 | emailLinkBooting = true; |
| 4268 | // The pre-paint gate already put the waiting face up (html.siwa-wait) — |
| 4269 | // the clean card never shows. |
| 4270 | const m = location.hash.match(/^#email=([^&]*)&code=(\d{6})$/); |
| 4271 | history.replaceState(null, "", location.pathname + location.search); |
| 4272 | if (m) { |
| 4273 | finishEmailLink(decodeURIComponent(m[1]), m[2]); |
| 4274 | } else { |
| 4275 | // A mangled link (a mail client truncated it): lift to the doors. |
| 4276 | finishHomeLoader({ immediate: true }); |
| 4277 | setStatus("That sign-in link didn’t work — request a fresh code.", "error"); |
| 4278 | } |
| 4279 | } |
| 4280 | |
| 4281 | $("account-menu-btn").addEventListener("pointerdown", () => { kebabPointerOpen = true; }); |
| 4282 | $("account-menu-btn").addEventListener("click", () => { |
| 4283 | if (accountMenuOpen()) closeAccountMenu(true); else openAccountMenu(); |
| 4284 | }); |
| 4285 | // Sign out is technically instant (local state clears) — the ~700ms beat |
| 4286 | // makes it read as the event it is (Paul's rule: meaningful transitions |
| 4287 | // earn a working state; case-by-case, not universal). |
| 4288 | $("signout").addEventListener("click", () => { |
| 4289 | closeAccountMenu(false); |
| 4290 | const card = document.querySelector("main.card"); |
| 4291 | card.classList.add("waiting"); |
| 4292 | setTimeout(() => { |
| 4293 | card.classList.remove("waiting"); |
| 4294 | signOut(); |
| 4295 | }, 700); |
| 4296 | }); |
| 4297 | // Sign-in options (issue #1098): the kebab's serial step family. The list's |
| 4298 | // own controls (row kebabs, first-add buttons) are wired per render. |
| 4299 | $("how-signin").addEventListener("click", () => { closeAccountMenu(false); openMethods(); }); |
| 4300 | $("methods-back").addEventListener("click", closeMethods); |
| 4301 | $("methods-confirm-back").addEventListener("click", () => { |
| 4302 | pendingIntent = null; // backing out cancels the parked change |
| 4303 | backToMethodsList("", "", { reload: false }); |
| 4304 | }); |
| 4305 | $("methods-confirm-passkey").addEventListener("click", confirmMethodsPasskey); |
| 4306 | $("methods-confirm-apple").addEventListener("click", confirmMethodsApple); |
| 4307 | $("methods-confirm-google").addEventListener("click", (e) => confirmMethodsOidc("google", e)); |
| 4308 | $("methods-confirm-microsoft").addEventListener("click", (e) => confirmMethodsOidc("microsoft", e)); |
| 4309 | $("methods-confirm-github").addEventListener("click", (e) => confirmMethodsOidc("github", e)); |
| 4310 | $("methods-confirm-email").addEventListener("click", confirmMethodsEmail); |
| 4311 | $("methods-cemail-form").addEventListener("submit", cemailSend); |
| 4312 | $("methods-cemail-code-form").addEventListener("submit", cemailVerify); |
| 4313 | $("methods-email-back").addEventListener("click", () => backToMethodsList("", "", { reload: false })); |
| 4314 | $("methods-email-form").addEventListener("submit", methodsEmailSend); |
| 4315 | $("methods-email-code-form").addEventListener("submit", methodsEmailVerify); |
| 4316 | $("methods-passkey-back").addEventListener("click", () => backToMethodsList("", "", { reload: false })); |
| 4317 | $("methods-passkey-create").addEventListener("click", addPasskeyCeremony); |
| 4318 | // The combine step (#1098's merge): offer → prove → its spaces → the merge. |
| 4319 | $("combine-offer-go").addEventListener("click", openCombineStep); |
| 4320 | $("combine-back").addEventListener("click", () => { |
| 4321 | combine = null; // cancel leaves both accounts untouched |
| 4322 | backToMethodsList("", "", { reload: false }); |
| 4323 | }); |
| 4324 | $("combine-code-form").addEventListener("submit", combineCodeVerify); |
| 4325 | $("combine-resend").addEventListener("click", combineRequestCode); |
| 4326 | $("combine-apple-go").addEventListener("click", combineAppleProve); |
| 4327 | $("combine-unlock").addEventListener("click", combineUnlock); |
| 4328 | $("combine-skip").addEventListener("click", combineSkip); |
| 4329 | $("combine-skip-yes").addEventListener("click", combineSkipYes); |
| 4330 | $("combine-skip-no").addEventListener("click", combineVaultStage); |
| 4331 | $("open-create").addEventListener("click", openCreate); |
| 4332 | $("plan-back").addEventListener("click", closePlan); |
| 4333 | $("plan-continue").addEventListener("click", planContinue); |
| 4334 | // Back from the slider: the list normally (it's the only step there was), or |
| 4335 | // the plan screen when a bounce put one behind us — the chosen tier survives. |
| 4336 | $("create-back").addEventListener("click", () => { |
| 4337 | if (planPaidRequired) { |
| 4338 | fader?.stop(); |
| 4339 | $("create").classList.add("hidden"); |
| 4340 | $("create-plan").classList.remove("hidden"); |
| 4341 | return; |
| 4342 | } |
| 4343 | closeCreate(); |
| 4344 | }); |
| 4345 | $("create-done").addEventListener("click", closeCreate); |
| 4346 | // (Creating a space is the fader now — mounted in openCreate; see fader.js.) |
| 4347 | $("archive-back").addEventListener("click", closeArchive); |
| 4348 | $("upgrade-back").addEventListener("click", closeUpgrade); |
| 4349 | $("claim-back").addEventListener("click", closeClaim); |
| 4350 | $("archive-cancel").addEventListener("click", closeArchiveStern); |
| 4351 | $("delete-access").addEventListener("click", deleteAccess); |
| 4352 | $("destroy-forever").addEventListener("click", destroyForEveryone); |
| 4353 | // The typed address arms both delete actions — exact match only. The last-admin |
| 4354 | // hold outranks arming for "Delete your access" (destroy is the sanctioned path, |
| 4355 | // so it still arms). |
| 4356 | $("delete-arm").addEventListener("input", () => { |
| 4357 | const armed = $("delete-arm").value.trim() === $("stern-host").textContent; |
| 4358 | $("delete-access").disabled = !armed || sternSoleAdmin; |
| 4359 | $("destroy-forever").disabled = !armed; |
| 4360 | }); |
| 4361 | // Live fuzzy filter: re-render the (unlocked) list on each keystroke. The input |
| 4362 | // lives outside #spaces, so re-rendering the lists never steals its focus. |
| 4363 | $("space-search").addEventListener("input", () => { if (vault) showVault(); }); |
| 4364 | |
| 4365 | // Learn whether this home server offers self-serve space creation, and reveal |
| 4366 | // the + on the list if it does (and we're already signed in — session rehydrate). |
| 4367 | apiJSON("/api/features") |
| 4368 | .then((f) => { |
| 4369 | spacesEnabled = !!f.spaces; |
| 4370 | // The no-account demo path — newcomers only: a browser that has ever |
| 4371 | // signed in (the #1112 marker) has outgrown the demo card. |
| 4372 | $("demo-offer").classList.toggle("hidden", !f.demo || everSignedIn()); |
| 4373 | if (vault) $("open-create").classList.toggle("hidden", !canCreateSpace()); |
| 4374 | // If the list rendered before this answer, re-render: the lazy ledger read |
| 4375 | // (Upgrade affordance) only triggers once spacesEnabled is known. |
| 4376 | if (vault && !$("vault").classList.contains("hidden")) showVault(); |
| 4377 | }) |
| 4378 | .catch(() => { /* older home server without the endpoint: leave the + hidden */ }); |
| 4379 | |
| 4380 | // The ambient smoke + motes background (bootscene.js) drifts behind every state |
| 4381 | // of the page, for everyone — started once, left running. |
| 4382 | startHomeLoader(); |
| 4383 | |
| 4384 | // The composing veil (raised pre-paint by the inline gate) lifts only now — |
| 4385 | // composeCard() has run, so the card's first visible frame is its final |
| 4386 | // composition. compose-reveal keeps the lift near-instant. |
| 4387 | document.documentElement.classList.add("compose-reveal"); |
| 4388 | document.documentElement.classList.remove("composing"); |
| 4389 | requestAnimationFrame(() => requestAnimationFrame(() => |
| 4390 | document.documentElement.classList.remove("compose-reveal"))); |
| 4391 | |
| 4392 | // Rehydrate a same-tab session (e.g. a reload): the DEK and token are still in |
| 4393 | // sessionStorage, so the vault can reopen without a ceremony. The loading chrome |
| 4394 | // (the Eleven lockup + tagline, already up pre-paint via html.booting) hides the |
| 4395 | // Sign-in flash until the list has decrypted; finishHomeLoader lifts it — fading |
| 4396 | // the content in over the smoke — whether we land on the vault or, if the session |
| 4397 | // went stale, back on Sign-in. |
| 4398 | if (state.token && state.dek) { |
| 4399 | (async () => { |
| 4400 | try { |
| 4401 | const got = await fetchBlob(state.token); |
| 4402 | if (got) { |
| 4403 | dekKey = await importDEK(state.dek); |
| 4404 | const opened = await openBlob(got.bytes, dekKey); |
| 4405 | vault = { obj: opened.obj, keys: opened.keys, version: got.version }; |
| 4406 | setStatus(""); |
| 4407 | showVault(); |
| 4408 | } |
| 4409 | } catch { /* token or DEK went stale; the button still works */ } |
| 4410 | finally { finishHomeLoader(); } |
| 4411 | })(); |
| 4412 | } else if (!siwaBooting && !oidcBooting && !oauthBooting && !emailLinkBooting) { |
| 4413 | // The cold open (stay signed in, Option A): no tab session, but a live |
| 4414 | // persisted token in localStorage. Adopt it and land exactly like an anchor |
| 4415 | // sign-in — the sealed prompt when a blob exists, the empty list when not; |
| 4416 | // the DEK was never persisted, so the passkey ceremony stays the only way |
| 4417 | // to plaintext. Acceptance rolls the 30-day expiry; a REJECTED token |
| 4418 | // (revoked server-side) clears the persisted session and falls to the |
| 4419 | // doors — a flaky network doesn't, it just gets this boot no further. |
| 4420 | // Every path lifts the pre-paint cover. |
| 4421 | const saved = persistedSession(); |
| 4422 | if (saved) { |
| 4423 | (async () => { |
| 4424 | try { |
| 4425 | await enterAnchorSession({ token: saved.token, created: false }); |
| 4426 | persistSession(saved.token, saved.method); // accepted — another 30 days |
| 4427 | } catch (e) { |
| 4428 | if (/\b401\b/.test((e && e.message) || "")) clearPersistedSession(); |
| 4429 | } finally { finishHomeLoader(); } |
| 4430 | })(); |
| 4431 | } else if (document.documentElement.classList.contains("booting")) { |
| 4432 | // The gate raised the cover for a session that didn't survive the read |
| 4433 | // (expired between frames, or unparseable) — tidy up and lift to the doors. |
| 4434 | clearPersistedSession(); |
| 4435 | finishHomeLoader(); |
| 4436 | } |
| 4437 | } |
| 4438 | |