| index 475dac0..d4b278c 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -305,9 +305,10 @@ function composeCard() { |
| async function signIn() { |
| setSignedOutBusy(true); |
| setStatus("Confirming it’s you…", "busy"); |
| - const { token, credId, prf } = await runLogin(loginBegin); |
| + const { token, credId, prf, proof } = await runLogin(loginBegin); |
| state.token = token; |
| state.credId = credId; |
| + noteProof(proof); // fills the confirm-step pocket (Sign-in options) |
| markSignedIn("passkey"); |
| persistSession(token, "passkey"); |
| const got = await fetchBlob(token); |
| @@ -374,6 +375,7 @@ async function createPasskey() { |
| } |
| state.token = reg.token; |
| state.credId = reg.credId; |
| + noteProof(reg.proof); // register/finish mints one too (finishCeremony) |
| markSignedIn("passkey"); // register/finish is a sign-in too (#1112) |
| persistSession(reg.token, "passkey"); |
| |
| @@ -430,9 +432,16 @@ function releaseApplePending() { |
| } |
| window.removeEventListener("pointerdown", releaseApplePending, true); |
| window.removeEventListener("keydown", releaseApplePending, true); |
| + // A cancelled trip must not leave the methods step's round-trip markers |
| + // behind — a stale one would misroute a LATER Apple sign-in into link mode. |
| + try { |
| + sessionStorage.removeItem("home_siwa_link"); |
| + sessionStorage.removeItem("home_methods_return"); |
| + sessionStorage.removeItem("home_methods_intent"); |
| + } catch { /* nothing stored */ } |
| } |
| |
| -function startAppleSignIn(e) { |
| +function startAppleSignIn(e, opts = {}) { |
| // Tap: the button spins and the REST of the form fades away — the wait |
| // for Apple's sheet is just the button at work. By 1.6s the sheet is up, |
| // so the smoke cover takes over then; post-fingerprint there is nothing |
| @@ -450,6 +459,21 @@ function startAppleSignIn(e) { |
| window.addEventListener("keydown", releaseApplePending, true); |
| const rand = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join(""); |
| const st = "web." + rand; |
| + // The methods step's round-trip markers ride sessionStorage beside the |
| + // state: linkProof turns the #siwa= return into "add Apple to MY account" |
| + // (link:true + the stashed proof), methodsReturn routes a confirm-with- |
| + // Apple sign-in straight back to the step, and intent is the serialized |
| + // in-progress change (intentToken) so the return RESUMES it instead of |
| + // landing on the bare list. Set fresh or CLEARED per trip, so a stale |
| + // marker from an abandoned attempt can never misroute later. |
| + try { |
| + if (opts.linkProof) sessionStorage.setItem("home_siwa_link", JSON.stringify(opts.linkProof)); |
| + else sessionStorage.removeItem("home_siwa_link"); |
| + if (opts.methodsReturn) sessionStorage.setItem("home_methods_return", "1"); |
| + else sessionStorage.removeItem("home_methods_return"); |
| + if (opts.intent) sessionStorage.setItem("home_methods_intent", opts.intent); |
| + else sessionStorage.removeItem("home_methods_intent"); |
| + } catch { /* no storage — the return leg lands as a plain sign-in */ } |
| sessionStorage.setItem("home_siwa_state", st); |
| location.href = "https://appleid.apple.com/auth/authorize" + |
| "?client_id=com.elevenmessenger.signin" + |
| @@ -465,7 +489,7 @@ function startAppleSignIn(e) { |
| // pre-paint waiting face (smoke + ring, html.siwa-wait) is up the whole time; |
| // verify success earns the arrival: ring gives way to the Eleven lockup, |
| // a beat to land, then the cover lifts as the spaces screen slides up. |
| -async function finishSiwa(idToken) { |
| +async function finishSiwa(idToken, backToMethods = false, intentStr = "") { |
| setStatus("Confirming it’s you…", "busy"); |
| try { |
| const res = await apiJSON("/api/siwa/verify", { |
| @@ -473,16 +497,36 @@ async function finishSiwa(idToken) { |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ identity_token: idToken }), |
| }); |
| + noteProof(res.proof); // fills the confirm-step pocket (Sign-in options) |
| markSignedIn("apple"); |
| persistSession(res.token, "apple"); |
| await enterAnchorSession(res); // renders the landing beneath the cover |
| } catch { |
| + if (backToMethods && state.token) { |
| + // A failed confirm-with-Apple: the tab's session is untouched — land |
| + // back on the list with the refusal (the intent was already consumed). |
| + await openMethods(); |
| + methodsStatus("That Apple sign-in couldn’t be confirmed — try again.", "error"); |
| + finishHomeLoader({ immediate: true }); |
| + return; |
| + } |
| // No brand moment for a sign-in that didn't happen: lift straight to |
| // the signed-out card and say what went wrong. |
| setStatus("That Apple sign-in couldn’t be confirmed — try again.", "error"); |
| finishHomeLoader({ immediate: true }); |
| return; |
| } |
| + if (backToMethods) { |
| + // A confirm-with-Apple round trip (the methods step sent us): no arrival |
| + // pageantry — straight back to Sign-in options, proof in the pocket. A |
| + // persisted intent resumes the interrupted change — the add step opens |
| + // (or the removal re-arms) instead of landing on the bare list. |
| + await openMethods(); |
| + const intent = parseIntentToken(intentStr); |
| + if (intent && freshProof()) runIntent(intent, freshProof()); |
| + finishHomeLoader({ immediate: true }); |
| + return; |
| + } |
| anchorArrival(); |
| } |
| |
| @@ -504,6 +548,112 @@ function anchorArrival() { |
| }, reduced ? 0 : 850); // 400ms brand fade + 450ms dwell |
| } |
| |
| +// finishSiwaLink completes an ADD-APPLE round trip (the #siwa= return with a |
| +// link marker): the ceremony proof stashed before leaving for Apple rode |
| +// sessionStorage — same tab — and must still be inside its 5-minute window on |
| +// return. The link verify never signs anyone in; this tab's own session |
| +// (which survived the navigation) is resumed instead, and the outcome lands |
| +// inline on the Sign-in options step. |
| +async function finishSiwaLink(idToken, markRaw) { |
| + let mark = null; |
| + try { mark = JSON.parse(markRaw); } catch { /* mangled marker — treat as lapsed */ } |
| + let outcome, conflict = false; |
| + if (!mark || !mark.proof || !(mark.expires > Date.now())) { |
| + // The proof's 5-minute window lapsed during the trip — re-prompt. |
| + outcome = { msg: "That took a little too long — confirm it's you again, then tap Add Apple.", kind: "error" }; |
| + } else { |
| + try { |
| + const res = await fetch("/api/siwa/verify", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json", "X-Ceremony-Proof": mark.proof }, |
| + body: JSON.stringify({ identity_token: idToken, link: true }), |
| + }); |
| + if (res.ok) { |
| + outcome = { msg: "Apple is now a way into your account.", kind: "ok" }; |
| + } else { |
| + // The server's refusals verbatim: a lapsed proof (401 — the line |
| + // asks to confirm again), or the 409 "that Apple ID already signs in |
| + // somewhere else" — the COMBINE doorway (#1098): the offer keys off |
| + // the status, never off message text. |
| + const msg = ((await res.text().catch(() => "")) || "").trim(); |
| + conflict = res.status === 409; |
| + outcome = { msg: msg || "Couldn't add Apple — try again.", kind: "error" }; |
| + } |
| + } catch { |
| + outcome = { msg: "Couldn't add Apple — try again.", kind: "error" }; |
| + } |
| + } |
| + if (await resumeAfterLinkReturn()) { |
| + await openMethods(); |
| + methodsStatus(outcome.msg, outcome.kind); |
| + if (conflict) { |
| + combine = { kind: "apple" }; |
| + showCombineOffer("methods-status"); |
| + } |
| + } else { |
| + // No session survived the round trip (storage cleared mid-flight) — the |
| + // doors, with the outcome where the visitor will read it. |
| + setStatus(outcome.msg, outcome.kind === "ok" ? "" : "error"); |
| + } |
| + finishHomeLoader({ immediate: true }); |
| +} |
| + |
| +// finishSiwaMergeProve is the merge-apple intent's return leg: the combine |
| +// step sent the person to Apple to prove the OTHER account is theirs. The |
| +// id_token verifies in SIGN-IN mode, but the answer is deliberately NOT |
| +// adopted — the absorbed account's token+proof are held in memory for the |
| +// merge only, and this tab's own (survivor) session resumes as-is. |
| +async function finishSiwaMergeProve(idToken) { |
| + let creds = null; |
| + try { |
| + const res = await apiJSON("/api/siwa/verify", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ identity_token: idToken }), |
| + }); |
| + creds = { token: res.token, proof: res.proof, expires: Date.now() + PROOF_TTL_MS }; |
| + } catch { /* falls through to the refusal below */ } |
| + if (!(await resumeAfterLinkReturn())) { |
| + setStatus("The combine couldn't resume — sign in and try again.", "error"); |
| + finishHomeLoader({ immediate: true }); |
| + return; |
| + } |
| + await openMethods(); |
| + if (!creds) { |
| + methodsStatus("That Apple sign-in couldn’t be confirmed — try combining again.", "error"); |
| + finishHomeLoader({ immediate: true }); |
| + return; |
| + } |
| + combine = { kind: "apple", absorbedToken: creds.token, absorbedProof: creds.proof, absorbedExpires: creds.expires }; |
| + openCombineStep(); // proven — lands straight on the spaces stage |
| + finishHomeLoader({ immediate: true }); |
| +} |
| + |
| +// resumeAfterLinkReturn rebuilds the signed-in view after an add-Apple round |
| +// trip: the tab's own session survived the navigation (sessionStorage), or |
| +// the stay-signed-in token did (localStorage). True once a signed-in screen |
| +// is up again; false means every session evaporated mid-trip. |
| +async function resumeAfterLinkReturn() { |
| + try { |
| + if (state.token && state.dek) { |
| + const got = await fetchBlob(state.token); |
| + if (got) { |
| + dekKey = await importDEK(state.dek); |
| + const opened = await openBlob(got.bytes, dekKey); |
| + vault = { obj: opened.obj, keys: opened.keys, version: got.version }; |
| + showVault(); |
| + return true; |
| + } |
| + } |
| + const saved = state.token ? { token: state.token } : persistedSession(); |
| + if (saved) { |
| + await enterAnchorSession({ token: saved.token, created: false }); |
| + return true; |
| + } |
| + } catch { /* fall through to the doors */ } |
| + return false; |
| +} |
| + |
| // The email door (email.go): type your address, get a 6-digit code, type it — |
| // find-or-create like Apple. The address goes only into the two request |
| // bodies; nothing here stores it. It's a card step of its own — the doors |
| @@ -639,6 +789,7 @@ async function verifyEmailCode(e) { |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ email, code }), |
| }), spinFloor]); |
| + noteProof(res.proof); // fills the confirm-step pocket (Sign-in options) |
| markSignedIn("email"); |
| persistSession(res.token, "email"); |
| await enterAnchorSession(res); |
| @@ -666,6 +817,7 @@ async function finishEmailLink(email, code) { |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ email, code }), |
| }); |
| + noteProof(res.proof); // fills the confirm-step pocket (Sign-in options) |
| markSignedIn("email"); |
| persistSession(res.token, "email"); |
| await enterAnchorSession(res); // renders the landing beneath the cover |
| @@ -737,6 +889,13 @@ function signOut() { |
| $("upgrade").classList.add("hidden"); |
| $("claim").classList.add("hidden"); |
| $("archive").classList.add("hidden"); |
| + for (const s of METHOD_SCREENS) $(s).classList.add("hidden"); |
| + resetMethodsSteps(); |
| + pendingIntent = null; |
| + combine = null; |
| + anchors = null; |
| + armedRemove = ""; |
| + ceremonyProof = null; // proofs are per-person, never per-browser |
| archiving = null; |
| clearUndo(); |
| $("spaces").textContent = ""; |
| @@ -755,7 +914,8 @@ function signOut() { |
| // ---- the account menu (issue #1098's single control) --------------------------- |
| |
| // One kebab in the vault header collects everything about the account itself: |
| -// Sign out today; "How you sign in" and account-merge join it later (#1098). |
| +// "Sign-in options" (the methods step family below) and Sign out; |
| +// account-merge joins it later (#1098's next slice). |
| // Menu semantics by hand: focus lands on the first item on open and returns |
| // to the kebab on close; Escape and click-outside both close. |
| function accountMenuOpen() { |
| @@ -796,6 +956,1052 @@ function accountMenuKey(e) { |
| if (e.key === "Escape") { e.preventDefault(); closeAccountMenu(true); } |
| } |
| |
| +// ---- sign-in options (issue #1098) ---------------------------------------------- |
| + |
| +// The ceremony-proof pocket. Removing or adding a sign-in method needs a |
| +// FRESH proof (the server holds them to a 5-minute window — stateless.go's |
| +// ceremonyWindow); every ceremony that mints one drops it here, and |
| +// freshProof answers only while a client-side mirror of that window still |
| +// has margin, so a call never leaves with a proof that will be refused on |
| +// arrival. In-memory only, like the passkey path's transient use. |
| +const PROOF_TTL_MS = 5 * 60 * 1000; |
| +const PROOF_MARGIN_MS = 10 * 1000; |
| +let ceremonyProof = null; // { proof, expires } | null |
| +function noteProof(proof) { |
| + if (proof) ceremonyProof = { proof, expires: Date.now() + PROOF_TTL_MS }; |
| +} |
| +function freshProof() { |
| + return ceremonyProof && ceremonyProof.expires - PROOF_MARGIN_MS > Date.now() |
| + ? ceremonyProof.proof : null; |
| +} |
| + |
| +// The kind vocabulary, generalized: every sign-in method the product knows, |
| +// in listing order. A linked kind's ADD lives in each of its rows' kebab (a |
| +// plain "Add another" — the row already names the kind); a kind with ZERO |
| +// linked entries gets a big first-add button below the list instead. Future |
| +// doors (google/microsoft) slot in here — same shape, gated by their DOORS |
| +// flag — with no restructuring. |
| +const METHOD_KINDS = [ |
| + { kind: "passkey", name: "Passkey", first: "Add a passkey", |
| + enabled: () => !!window.PublicKeyCredential, start: (e) => startAddPasskey(e) }, |
| + { kind: "siwa", name: "Apple", first: "Add Apple", |
| + enabled: () => DOORS.apple, start: (e) => startAddApple(e) }, |
| + { kind: "email", name: "Email", first: "Add email", |
| + enabled: () => DOORS.email, start: (e) => startAddEmail(e) }, |
| +]; |
| + |
| +// The step family is SERIAL card screens — one at a time, each with a back: |
| +// the list, the confirm beat, add-email, add-passkey. (Add-Apple's "step" is |
| +// the Apple journey itself.) showMethodsScreen swaps within the family; |
| +// callers manage focus. |
| +const METHOD_SCREENS = ["methods", "methods-confirm", "methods-email", "methods-passkey", "methods-combine"]; |
| +function showMethodsScreen(id) { |
| + closeRowMenu(false); |
| + for (const s of METHOD_SCREENS) $(s).classList.toggle("hidden", s !== id); |
| +} |
| + |
| +function methodsStatus(msg, kind) { |
| + const el = $("methods-status"); |
| + el.textContent = msg || ""; |
| + el.className = "status" + (kind ? " " + kind : ""); |
| +} |
| +function confirmStatus(msg, kind) { |
| + const el = $("methods-confirm-status"); |
| + el.textContent = msg || ""; |
| + el.className = "status" + (kind ? " " + kind : ""); |
| +} |
| +function emailStepStatus(msg, kind) { |
| + const el = $("methods-email-status"); |
| + el.textContent = msg || ""; |
| + el.className = "status" + (kind ? " " + kind : ""); |
| +} |
| +function passkeyStepStatus(msg, kind) { |
| + const el = $("methods-passkey-status"); |
| + el.textContent = msg || ""; |
| + el.className = "status" + (kind ? " " + kind : ""); |
| +} |
| + |
| +// anchors is the last-fetched listing (GET /api/anchors) — renderMethods' |
| +// input, and what the confirm step consults to pick a ceremony this account |
| +// can actually run. armedRemove holds the row currently in its inline |
| +// are-you-sure state, so re-renders keep it armed. |
| +let anchors = null; |
| +let armedRemove = ""; |
| +// pendingIntent is the change the confirm step interrupted, AS DATA — the one |
| +// continuation system behind every confirm path. In-page ceremonies resume it |
| +// from memory (payloads intact); the confirm-with-Apple round trip carries |
| +// its intentToken through sessionStorage and the return leg resumes the same |
| +// way. Shapes: { kind: "add-email" | "add-passkey" | "add-apple" }, { kind: |
| +// "verify-email", email, code } (memory-only payload), { kind: "remove", |
| +// anchorKind, id, armed }. |
| +let pendingIntent = null; |
| + |
| +// intentToken / parseIntentToken serialize a pending intent for the Apple |
| +// confirmation's full-page round trip (sessionStorage, set-or-cleared per |
| +// trip like the other markers). Only the change's IDENTITY rides — never a |
| +// payload: a typed address/code stays in memory, so "verify-email" degrades |
| +// to reopening the add-email step (the same mailed code can be retyped |
| +// there), and a removal's passed are-you-sure is deliberately dropped (a |
| +// reloaded page re-arms rather than auto-deleting). Anchor ids are |
| +// base64url/hex, so ":" can't ambiguate the remove form. |
| +function intentToken(intent) { |
| + if (!intent) return ""; |
| + if (intent.kind === "remove") return "remove:" + intent.anchorKind + ":" + intent.id; |
| + if (intent.kind === "verify-email") return "add-email"; |
| + if (intent.kind === "combine-finish") return ""; // the combine context is memory-only |
| + return intent.kind; // add-email | add-passkey | add-apple |
| +} |
| +function parseIntentToken(s) { |
| + if (s === "add-email" || s === "add-passkey" || s === "add-apple") return { kind: s }; |
| + const m = /^remove:([^:]+):(.+)$/.exec(s || ""); |
| + return m ? { kind: "remove", anchorKind: m[1], id: m[2] } : null; |
| +} |
| + |
| +// runIntent performs a parked change once a fresh proof is in hand. Callers |
| +// guarantee the pocket is fresh; the intent decides its own navigation. |
| +function runIntent(intent, proof) { |
| + switch (intent.kind) { |
| + case "add-email": openMethodsEmailStep(); return; |
| + case "add-passkey": openMethodsPasskeyStep(); return; |
| + case "add-apple": startAddApple(null); return; // pocket is fresh — straight to Apple in link mode |
| + case "verify-email": |
| + // In-memory only (never serialized): retry the exact typed code — it |
| + // survived, link mode gates on the proof BEFORE redeeming (email.go). |
| + backToMethodsList("", "", { reload: false }); |
| + finishEmailAdd(intent.email, intent.code, proof); |
| + return; |
| + case "combine-finish": |
| + // In-memory only: the survivor's proof lapsed mid-combine; finish now. |
| + combineFinish(intent.movedSpaces || 0); |
| + return; |
| + case "remove": { |
| + const a = (anchors || []).find((x) => x.kind === intent.anchorKind && x.id === intent.id); |
| + backToMethodsList("", "", { reload: !a }); // row gone meanwhile → just the fresh list |
| + if (!a) return; |
| + if (intent.armed) { |
| + // The are-you-sure was passed right before an in-page confirm — |
| + // complete the removal. |
| + doRemoveAnchor(a, proof); |
| + } else { |
| + // The Apple round trip reloaded the page: the destructive tap stays |
| + // explicit — re-arm THAT row's are-you-sure instead of auto-deleting. |
| + armedRemove = intent.anchorKind + "/" + intent.id; |
| + renderMethods(); |
| + focusMethodRow(armedRemove, "button.danger-ink"); |
| + } |
| + return; |
| + } |
| + } |
| +} |
| + |
| +async function openMethods() { |
| + hideVault(); |
| + resetMethodsSteps(); |
| + armedRemove = ""; |
| + pendingIntent = null; |
| + methodsStatus(""); |
| + showMethodsScreen("methods"); |
| + $("methods-title").focus(); // announce the step; rows load in beneath |
| + await loadAnchors(); |
| +} |
| + |
| +function closeMethods() { |
| + resetMethodsSteps(); |
| + pendingIntent = null; |
| + combine = null; // abandoning the flow touches neither account |
| + for (const s of METHOD_SCREENS) $(s).classList.add("hidden"); |
| + // Back to whichever signed-in view this session actually has — anchor-born |
| + // sessions may still be sealed (no vault object to render). |
| + if (vault) showVault(); else showSealedVault(); |
| +} |
| + |
| +// backToMethodsList returns from a sub-step (or finishes a flow) to the |
| +// list, with the outcome line where the person lands. |
| +function backToMethodsList(msg, kind, { reload = true } = {}) { |
| + showMethodsScreen("methods"); |
| + $("methods-title").focus(); |
| + methodsStatus(msg || "", kind); |
| + if (reload) loadAnchors(); |
| +} |
| + |
| +// resetMethodsSteps parks every sub-step — shared by open/close/sign-out so |
| +// no half-finished round trip greets the next visit. |
| +function resetMethodsSteps() { |
| + // The confirm step. |
| + $("methods-confirm-passkey").classList.add("hidden"); |
| + $("methods-confirm-apple").classList.add("hidden"); |
| + $("methods-cemail").classList.add("hidden"); |
| + $("methods-cemail-form").classList.remove("hidden"); |
| + $("methods-cemail-code-form").classList.add("hidden"); |
| + $("methods-cemail-addr").value = ""; |
| + $("methods-cemail-code").value = ""; |
| + confirmStatus(""); |
| + // The add-email step. |
| + $("methods-email-form").classList.remove("hidden"); |
| + $("methods-email-code-form").classList.add("hidden"); |
| + $("methods-email-addr").value = ""; |
| + $("methods-email-code").value = ""; |
| + emailStepStatus(""); |
| + // The add-passkey step. |
| + passkeyAddBegin = null; |
| + passkeyStepStatus(""); |
| + // The combine step's stages + the 409 offer (the combine CONTEXT itself |
| + // survives — only closing the family or finishing clears it). |
| + $("combine-offer").classList.add("hidden"); |
| + $("combine-prove").classList.add("hidden"); |
| + $("combine-code").classList.add("hidden"); |
| + $("combine-apple-go").classList.add("hidden"); |
| + $("combine-code-input").value = ""; |
| + $("combine-vault").classList.add("hidden"); |
| + $("combine-unlock").classList.add("hidden"); |
| + $("combine-skip").classList.add("hidden"); |
| + $("combine-skip-confirm").classList.add("hidden"); |
| + combineStatus(""); |
| +} |
| + |
| +async function loadAnchors() { |
| + try { |
| + const r = await apiJSON("/api/anchors", { headers: { Authorization: "Bearer " + state.token } }); |
| + anchors = r.anchors || []; |
| + renderMethods(); |
| + } catch (e) { |
| + methodsStatus(e.message || "Couldn't load your sign-in options — try again.", "error"); |
| + } |
| +} |
| + |
| +// methodAdded is the row's quiet second line — "added 12 Jul", year only |
| +// when it isn't this year's. |
| +function methodAdded(ts) { |
| + if (!ts) return ""; |
| + const d = new Date(ts * 1000); |
| + const opts = { day: "numeric", month: "short" }; |
| + if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric"; |
| + return "added " + d.toLocaleDateString(undefined, opts); |
| +} |
| + |
| +function renderMethods() { |
| + closeRowMenu(false); |
| + const box = $("methods-list"); |
| + box.textContent = ""; |
| + if (anchors && anchors.length) { |
| + const ul = document.createElement("ul"); |
| + ul.className = "methods"; |
| + for (const a of anchors) ul.append(methodRow(a)); |
| + box.append(ul); |
| + } else if (anchors) { |
| + // Can't normally happen (a signed-in account always has a way in) — a |
| + // racing removal from another device can strand a beat, though. |
| + box.append(emptyLine("No sign-in methods to show.")); |
| + } |
| + renderMethodsAdd(); |
| +} |
| + |
| +// renderMethodsAdd builds the big first-add buttons: one per LIVE kind with |
| +// zero linked entries (a linked kind's add lives in its rows' kebab). |
| +function renderMethodsAdd() { |
| + const box = $("methods-add"); |
| + box.textContent = ""; |
| + for (const k of METHOD_KINDS) { |
| + if (!k.enabled()) continue; |
| + if ((anchors || []).some((a) => a.kind === k.kind)) continue; |
| + const btn = document.createElement("button"); |
| + btn.type = "button"; |
| + btn.className = "secondary"; |
| + btn.textContent = k.first; |
| + btn.addEventListener("click", (e) => k.start(e)); |
| + box.append(btn); |
| + } |
| +} |
| + |
| +// The same three dots as the account kebab (static, in-code SVG). |
| +const KEBAB_SVG = |
| + '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">' + |
| + '<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>'; |
| + |
| +function methodRow(a) { |
| + const li = document.createElement("li"); |
| + const key = a.kind + "/" + a.id; |
| + li.dataset.key = key; |
| + const def = METHOD_KINDS.find((k) => k.kind === a.kind); |
| + const label = def ? def.name : a.kind; |
| + const added = methodAdded(a.created_at); |
| + const longName = label + (added ? ", " + added : ""); // for the aria labels |
| + |
| + const main = document.createElement("div"); |
| + main.className = "method-main"; |
| + const name = document.createElement("span"); |
| + name.className = "method-name"; |
| + name.textContent = label; |
| + main.append(name); |
| + if (added) { |
| + const sub = document.createElement("span"); |
| + sub.className = "method-added"; |
| + sub.textContent = added; |
| + main.append(sub); |
| + } |
| + li.append(main); |
| + |
| + if (armedRemove === key) { |
| + // The inline are-you-sure beat (never a browser confirm()): the row's |
| + // own controls become the question. |
| + const yes = document.createElement("button"); |
| + yes.type = "button"; |
| + yes.className = "pill danger-ink"; |
| + yes.textContent = "Remove"; |
| + yes.setAttribute("aria-label", "Yes, remove " + longName); |
| + yes.addEventListener("click", () => { |
| + armedRemove = ""; |
| + renderMethods(); // disarm before the (async) removal repaints |
| + focusMethodRow(key, "button"); // keep focus off <body> mid-flight |
| + performRemove(a); |
| + }); |
| + const no = document.createElement("button"); |
| + no.type = "button"; |
| + no.className = "pill"; |
| + no.textContent = "Keep"; |
| + no.setAttribute("aria-label", "Keep " + longName); |
| + no.addEventListener("click", () => { |
| + armedRemove = ""; |
| + renderMethods(); |
| + focusMethodRow(key, "button"); |
| + }); |
| + li.append(yes, no); |
| + } else { |
| + li.append(rowKebab(key, def, longName, a)); |
| + } |
| + return li; |
| +} |
| + |
| +// rowKebab is the row's own menu — the account kebab's vocabulary scaled |
| +// down: aria-haspopup/expanded, focus onto the first item, Escape and |
| +// outside-click both close (the shared manager keeps one open at a time). |
| +// It holds "Add another" (when the kind's door is live) and Remove. |
| +function rowKebab(key, def, longName, a) { |
| + const wrap = document.createElement("div"); |
| + wrap.className = "row-kebab-wrap"; |
| + const btn = document.createElement("button"); |
| + btn.type = "button"; |
| + btn.className = "icon-kebab"; |
| + btn.setAttribute("aria-haspopup", "menu"); |
| + btn.setAttribute("aria-expanded", "false"); |
| + btn.setAttribute("aria-label", "Options for " + longName); |
| + btn.innerHTML = KEBAB_SVG; |
| + const menu = document.createElement("div"); |
| + menu.className = "menu-pop hidden"; |
| + menu.setAttribute("role", "menu"); |
| + menu.setAttribute("aria-label", "Options for " + longName); |
| + if (def && def.enabled()) { |
| + const add = document.createElement("button"); |
| + add.type = "button"; |
| + add.setAttribute("role", "menuitem"); |
| + // Visibly just "Add another" — the row names the kind. The accessible |
| + // name carries it for menus read out of visual context ("Add another |
| + // Passkey"); label-in-name holds, the visible text is its prefix. |
| + add.textContent = "Add another"; |
| + add.setAttribute("aria-label", "Add another " + (def.name || a.kind)); |
| + add.addEventListener("click", (e) => { closeRowMenu(false); def.start(e); }); |
| + menu.append(add); |
| + } |
| + const rm = document.createElement("button"); |
| + rm.type = "button"; |
| + rm.setAttribute("role", "menuitem"); |
| + rm.className = "danger-ink"; |
| + rm.textContent = "Remove"; |
| + rm.setAttribute("aria-label", "Remove " + longName); |
| + rm.addEventListener("click", () => { |
| + closeRowMenu(false); |
| + armedRemove = key; |
| + renderMethods(); |
| + focusMethodRow(key, "button.danger-ink"); // the are-you-sure's Remove |
| + }); |
| + menu.append(rm); |
| + btn.addEventListener("pointerdown", () => { rowMenuPointerOpen = true; }); |
| + btn.addEventListener("click", () => toggleRowMenu(btn, menu)); |
| + wrap.append(btn, menu); |
| + return wrap; |
| +} |
| + |
| +// One row menu open at a time, with the account menu's open/close manners |
| +// (including its pointer-open focus-ring courtesy). |
| +let openRowMenuState = null; // { btn, menu } | null |
| +let rowMenuPointerOpen = false; |
| +function toggleRowMenu(btn, menu) { |
| + if (openRowMenuState && openRowMenuState.menu === menu) { closeRowMenu(true); return; } |
| + closeRowMenu(false); |
| + menu.classList.toggle("pointer-open", rowMenuPointerOpen); |
| + rowMenuPointerOpen = false; |
| + menu.classList.remove("hidden"); |
| + btn.setAttribute("aria-expanded", "true"); |
| + openRowMenuState = { btn, menu }; |
| + menu.querySelector('[role="menuitem"]').focus(); |
| + addEventListener("pointerdown", rowMenuOutside, true); |
| + addEventListener("keydown", rowMenuKey, true); |
| +} |
| +function closeRowMenu(refocus) { |
| + if (!openRowMenuState) return; |
| + openRowMenuState.menu.classList.add("hidden"); |
| + openRowMenuState.btn.setAttribute("aria-expanded", "false"); |
| + if (refocus) openRowMenuState.btn.focus(); |
| + openRowMenuState = null; |
| + removeEventListener("pointerdown", rowMenuOutside, true); |
| + removeEventListener("keydown", rowMenuKey, true); |
| +} |
| +function rowMenuOutside(e) { |
| + if (!e.target.closest || !e.target.closest(".row-kebab-wrap")) closeRowMenu(false); |
| +} |
| +function rowMenuKey(e) { |
| + if (e.key === "Escape") { e.preventDefault(); closeRowMenu(true); } |
| +} |
| + |
| +// focusMethodRow puts focus back where a re-render dropped it. |
| +function focusMethodRow(key, selector) { |
| + for (const li of $("methods-list").querySelectorAll("li")) { |
| + if (li.dataset.key === key) { li.querySelector(selector)?.focus(); return; } |
| + } |
| +} |
| + |
| +// performRemove: a fresh proof first — SERIALLY, via the confirm step when |
| +// the pocket is empty (list → confirm → back to the list → the DELETE) — |
| +// then doRemoveAnchor. The server's refusals — notably the last-method 409 |
| +// ("that's the last way into this account…") — show inline, verbatim. |
| +function performRemove(a) { |
| + const p = freshProof(); |
| + if (p) { doRemoveAnchor(a, p); return; } |
| + // armed: the are-you-sure was just passed, so an in-page confirm completes |
| + // the removal; the round-trip serialization drops the flag on purpose. |
| + pendingIntent = { kind: "remove", anchorKind: a.kind, id: a.id, armed: true }; |
| + openMethodsConfirmStep(); |
| +} |
| + |
| +async function doRemoveAnchor(a, proof) { |
| + methodsStatus("Removing…", "busy"); |
| + try { |
| + await apiJSON("/api/anchors/" + encodeURIComponent(a.kind) + "/" + encodeURIComponent(a.id), { |
| + method: "DELETE", |
| + headers: { "X-Ceremony-Proof": proof }, |
| + }); |
| + methodsStatus("Removed.", "ok"); |
| + } catch (e) { |
| + methodsStatus((e && e.message) || "Couldn't remove it.", "error"); |
| + } |
| + await loadAnchors(); // either way — a refusal means the list needs truth |
| + // The focused row may just have vanished — never strand focus on <body>. |
| + if (document.activeElement === document.body) $("methods-title").focus(); |
| +} |
| + |
| +function resumeIntent() { |
| + const intent = pendingIntent; |
| + pendingIntent = null; |
| + const p = freshProof(); |
| + if (intent && p) runIntent(intent, p); |
| +} |
| + |
| +// ---- the confirm step ----------------------------------------------------------- |
| + |
| +// openMethodsConfirmStep is the serial "it's really you" screen, FIRST |
| +// whenever an action needs a proof the pocket doesn't hold. It offers |
| +// whatever the account can actually run, best first: the passkey ceremony, |
| +// an email code re-verify (sign-in mode — the answer's proof is all we |
| +// keep), else a fresh Apple sign-in that lands straight back on the list. |
| +function openMethodsConfirmStep(note) { |
| + resetMethodsSteps(); // fresh forms; the parked change rides pendingIntent |
| + showMethodsScreen("methods-confirm"); |
| + const havePasskey = !!window.PublicKeyCredential && (anchors || []).some((x) => x.kind === "passkey"); |
| + const haveEmail = (anchors || []).some((x) => x.kind === "email"); |
| + $("methods-confirm-passkey").classList.toggle("hidden", !havePasskey); |
| + $("methods-confirm-apple").classList.toggle("hidden", havePasskey || haveEmail); |
| + const why = $("methods-confirm-why"); |
| + if (note) confirmStatus(note, "error"); |
| + if (havePasskey) { |
| + why.textContent = "Changing your sign-in options needs a quick check — confirm with your passkey and you'll carry straight on."; |
| + $("methods-confirm-passkey").focus(); |
| + } else if (haveEmail) { |
| + 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."; |
| + $("methods-cemail").classList.remove("hidden"); |
| + $("methods-cemail-addr").focus(); |
| + } else { |
| + why.textContent = "Changing your sign-in options needs a quick check — sign in with Apple again and you'll come straight back here."; |
| + $("methods-confirm-apple").focus(); |
| + } |
| +} |
| + |
| +async function confirmMethodsPasskey() { |
| + const btn = $("methods-confirm-passkey"); |
| + btn.disabled = true; |
| + confirmStatus("Confirming it’s you…", "busy"); |
| + try { |
| + // Only the proof is kept — this tab's session stays as it is (picking a |
| + // different account's passkey just makes the follow-on call miss, said |
| + // inline on the list). |
| + const { proof } = await runLogin(loginBegin); |
| + noteProof(proof); |
| + resumeIntent(); |
| + } catch (e) { |
| + confirmStatus(friendlyCeremonyError(e, "signin").message, "error"); |
| + } finally { |
| + prefetchLogin(); // ceremony options are single-use — ready the next |
| + btn.disabled = false; |
| + } |
| +} |
| + |
| +function confirmMethodsApple(e) { |
| + // A fresh Apple sign-in mints the proof (finishSiwa pockets it); the |
| + // markers route the return leg straight back into the flow: methodsReturn |
| + // lands on the step family, and the serialized intent RESUMES the parked |
| + // change (the add step opens, a removal re-arms) instead of the bare list. |
| + const intent = intentToken(pendingIntent); |
| + pendingIntent = null; // payloads can't survive the navigation; the token rides |
| + startAppleSignIn(e, { methodsReturn: true, intent }); |
| +} |
| + |
| +// The confirm step's email re-verify: the same two-step form as the door, |
| +// but the answer's TOKEN is deliberately dropped — the proof is the point, |
| +// and this tab keeps the session it already has. |
| +async function cemailSend(e) { |
| + e.preventDefault(); |
| + const email = $("methods-cemail-addr").value.trim(); |
| + const btn = $("methods-cemail-send"); |
| + btn.disabled = true; |
| + btn.classList.add("loading"); |
| + confirmStatus("Sending your code…", "busy"); |
| + const spinFloor = new Promise((r) => setTimeout(r, 900)); // same floor as the door |
| + try { |
| + await Promise.all([apiJSON("/api/email/request", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ email }), |
| + }), spinFloor]); |
| + confirmStatus("Check your email for a 6-digit code.", ""); |
| + $("methods-cemail-form").classList.add("hidden"); |
| + $("methods-cemail-code-form").classList.remove("hidden"); |
| + $("methods-cemail-code").focus(); |
| + } catch (err) { |
| + confirmStatus(err.message || "Couldn’t send the code — try again.", "error"); |
| + } finally { |
| + btn.disabled = false; |
| + btn.classList.remove("loading"); |
| + } |
| +} |
| + |
| +async function cemailVerify(e) { |
| + e.preventDefault(); |
| + const email = $("methods-cemail-addr").value.trim(); |
| + const code = $("methods-cemail-code").value.trim(); |
| + const btn = $("methods-cemail-verify"); |
| + btn.disabled = true; |
| + btn.classList.add("loading"); |
| + confirmStatus("Checking your code…", "busy"); |
| + try { |
| + const res = await apiJSON("/api/email/verify", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ email, code }), |
| + }); |
| + noteProof(res.proof); |
| + resumeIntent(); |
| + } catch (err) { |
| + confirmStatus(err.message || "That code didn’t work — try again.", "error"); |
| + } finally { |
| + btn.disabled = false; |
| + btn.classList.remove("loading"); |
| + } |
| +} |
| + |
| +// ---- the add steps -------------------------------------------------------------- |
| + |
| +// startAddApple begins the ADD-APPLE round trip: a fresh proof first — |
| +// serially, via the confirm step when needed — then the ordinary Apple |
| +// hand-off with the link marker, so the #siwa= return posts link:true |
| +// instead of signing in (the proof rides sessionStorage beside the state; |
| +// the server holds it to the same 5-minute window). |
| +function startAddApple(e) { |
| + const src = e && e.currentTarget; |
| + const go = (proof) => startAppleSignIn( |
| + // The pressed button spins when it's still on screen (the big first-add |
| + // button); a menu-born add has no visible button left, so a detached one |
| + // absorbs the spinner and the smoke cover carries the wait. |
| + { currentTarget: src && src.isConnected ? src : document.createElement("button") }, |
| + { linkProof: { proof, expires: ceremonyProof ? ceremonyProof.expires : Date.now() + PROOF_TTL_MS } }); |
| + const p = freshProof(); |
| + if (p) { go(p); return; } |
| + pendingIntent = { kind: "add-apple" }; |
| + openMethodsConfirmStep(); |
| +} |
| + |
| +// startAddEmail / startAddPasskey open their serial card steps — confirm |
| +// first when the pocket is empty (list → confirm → the add step → list). |
| +function startAddEmail() { |
| + if (freshProof()) { openMethodsEmailStep(); return; } |
| + pendingIntent = { kind: "add-email" }; |
| + openMethodsConfirmStep(); |
| +} |
| +function startAddPasskey() { |
| + if (freshProof()) { openMethodsPasskeyStep(); return; } |
| + pendingIntent = { kind: "add-passkey" }; |
| + openMethodsConfirmStep(); |
| +} |
| + |
| +function openMethodsEmailStep() { |
| + resetMethodsSteps(); |
| + showMethodsScreen("methods-email"); |
| + $("methods-email-addr").focus(); |
| +} |
| + |
| +async function methodsEmailSend(e) { |
| + e.preventDefault(); |
| + const email = $("methods-email-addr").value.trim(); |
| + const btn = $("methods-email-send"); |
| + btn.disabled = true; |
| + btn.classList.add("loading"); |
| + emailStepStatus("Sending your code…", "busy"); |
| + const spinFloor = new Promise((r) => setTimeout(r, 900)); |
| + try { |
| + await Promise.all([apiJSON("/api/email/request", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ email }), |
| + }), spinFloor]); |
| + emailStepStatus("Check your email for a 6-digit code.", ""); |
| + $("methods-email-form").classList.add("hidden"); |
| + $("methods-email-code-form").classList.remove("hidden"); |
| + $("methods-email-code").focus(); |
| + } catch (err) { |
| + emailStepStatus(err.message || "Couldn’t send the code — try again.", "error"); |
| + } finally { |
| + btn.disabled = false; |
| + btn.classList.remove("loading"); |
| + } |
| +} |
| + |
| +async function methodsEmailVerify(e) { |
| + e.preventDefault(); |
| + const email = $("methods-email-addr").value.trim(); |
| + const code = $("methods-email-code").value.trim(); |
| + const proof = freshProof(); |
| + if (!proof) { |
| + // The proof lapsed while the code round-tripped. The code survives — |
| + // link mode gates on the proof BEFORE redeeming (email.go) — so run the |
| + // confirm step again, then retry this exact verify. |
| + pendingIntent = { kind: "verify-email", email, code }; |
| + openMethodsConfirmStep("That took a little while — confirm it’s you again."); |
| + return; |
| + } |
| + const btn = $("methods-email-verify"); |
| + btn.disabled = true; |
| + btn.classList.add("loading"); |
| + emailStepStatus("Checking your code…", "busy"); |
| + try { |
| + await finishEmailAdd(email, code, proof); |
| + } finally { |
| + btn.disabled = false; |
| + btn.classList.remove("loading"); |
| + } |
| +} |
| + |
| +// finishEmailAdd posts the link verify and lands the outcome: success goes |
| +// back to the list with the good news; a refusal shows wherever the person |
| +// is (the add step, or the list after a confirm-step retry), verbatim. The |
| +// 409 ("that address already signs in somewhere else") is the COMBINE |
| +// doorway (#1098): the error line grows the offer that opens the combine |
| +// step — keyed off the status, never off message text. |
| +async function finishEmailAdd(email, code, proof) { |
| + const res = await fetch("/api/email/verify", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json", "X-Ceremony-Proof": proof }, |
| + body: JSON.stringify({ email, code, link: true }), |
| + }); |
| + if (res.ok) { |
| + backToMethodsList("Email added — you can sign in with it now.", "ok"); |
| + return; |
| + } |
| + const msg = ((await res.text().catch(() => "")) || "").trim() || "That code didn’t work — try again."; |
| + const onEmailStep = !$("methods-email").classList.contains("hidden"); |
| + if (onEmailStep) emailStepStatus(msg, "error"); else backToMethodsList(msg, "error"); |
| + if (res.status === 409) { |
| + combine = { kind: "email", email }; |
| + showCombineOffer(onEmailStep ? "methods-email-status" : "methods-status"); |
| + } |
| +} |
| + |
| +// The add-passkey step: one button, so navigator.credentials.create fires |
| +// inside a fresh tap (Safari drops the activation across a network await). |
| +// The register options are fetched WITH the proof at step open — that's what |
| +// makes the new passkey attach to THIS account instead of minting a fresh |
| +// one (webauthn.go's register/begin). |
| +let passkeyAddBegin = null; |
| + |
| +function openMethodsPasskeyStep() { |
| + resetMethodsSteps(); |
| + showMethodsScreen("methods-passkey"); |
| + $("methods-passkey-create").focus(); |
| + prefetchPasskeyAddBegin(); |
| +} |
| + |
| +function prefetchPasskeyAddBegin() { |
| + const proof = freshProof(); |
| + if (!proof) return; |
| + apiJSON("/api/register/begin", { method: "POST", headers: { "X-Ceremony-Proof": proof } }) |
| + .then((v) => { passkeyAddBegin = v; }) |
| + .catch(() => { passkeyAddBegin = null; }); |
| +} |
| + |
| +async function addPasskeyCeremony() { |
| + const btn = $("methods-passkey-create"); |
| + btn.disabled = true; |
| + passkeyStepStatus("Creating your passkey…", "busy"); |
| + let begin = passkeyAddBegin; |
| + passkeyAddBegin = null; // ceremony options are single-use |
| + try { |
| + if (!begin) { |
| + const proof = freshProof(); |
| + if (!proof) { |
| + // Sat on this step past the window — the confirm step runs again, |
| + // then reopens here with fresh proof-carrying options. |
| + pendingIntent = { kind: "add-passkey" }; |
| + openMethodsConfirmStep("That took a little while — confirm it’s you again."); |
| + return; |
| + } |
| + // The prefetch missed (slow network); fetching here may cost Safari's |
| + // activation — the retry tap then uses the fresh prefetch below. |
| + begin = await apiJSON("/api/register/begin", { method: "POST", headers: { "X-Ceremony-Proof": proof } }); |
| + } |
| + const reg = await runRegister(begin); |
| + noteProof(reg.proof); |
| + // When this tab holds the DEK and a blob already exists, wrap it for the |
| + // new credential too — the passkey then OPENS the list, not just signs |
| + // in. Sealed/anchor tabs (and blob-less accounts — an empty vault save |
| + // would trip the creation-eligibility gate, see createPasskey) skip it: |
| + // identity-only is still a real way in under the anchor model. |
| + if (dekKey && vault && vault.version !== "0" && reg.prf) { |
| + try { |
| + const kek = await kekFromPRF(reg.prf); |
| + vault.keys[reg.credId] = await wrapDEK(dekKey, kek); |
| + await saveVault(); |
| + } catch { /* the passkey works for sign-in; the wrap can land later */ } |
| + } |
| + backToMethodsList("Passkey added — one tap next time on this device.", "ok"); |
| + } catch (e) { |
| + prefetchPasskeyAddBegin(); // options were spent — ready a retry |
| + if (e && e.message === "no-prf") { |
| + passkeyStepStatus("This device can’t protect your sign-in here (no PRF support) — try a different device or password manager.", "error"); |
| + } else { |
| + passkeyStepStatus(formatCeremonyError(e, "create"), "error"); |
| + } |
| + } finally { |
| + btn.disabled = false; |
| + } |
| +} |
| + |
| +// ---- combine your accounts (#1098's merge) -------------------------------------- |
| + |
| +// The in-flight combine context, memory-only: which anchor kind collided |
| +// ("email" | "apple"), the typed address (email kind), and — once stage A |
| +// proves the other side — the ABSORBED account's credentials. Those are |
| +// deliberately never adopted as the session and never persisted: this tab |
| +// stays signed into the SURVIVOR; the absorbed token+proof exist only to |
| +// move its vault and feed absorb_proof to POST /api/account/merge |
| +// (merge.go). Cancel anywhere just drops this object — nothing destructive |
| +// happens before the explicit beats. |
| +let combine = null; // { kind, email?, absorbedToken?, absorbedProof?, absorbedExpires?, absorbedBlob?, absorbedPasskeyIds? } |
| + |
| +function combineStatus(msg, kind) { |
| + const el = $("combine-status"); |
| + el.textContent = msg || ""; |
| + el.className = "status" + (kind ? " " + kind : ""); |
| +} |
| + |
| +// showCombineOffer grows the offer under whichever status line carries the |
| +// 409 — the node moves beside it (like #newhere follows its surface) and |
| +// resets hide it again. |
| +function showCombineOffer(statusId) { |
| + $(statusId).insertAdjacentElement("afterend", $("combine-offer")); |
| + $("combine-offer").classList.remove("hidden"); |
| +} |
| + |
| +// absorbedProofFresh mirrors freshProof for the OTHER side's proof — the |
| +// merge and the absorbed-blob delete both refuse a stale one server-side, |
| +// so a lapsed proof re-runs the prove stage instead of burning a call. |
| +function absorbedProofFresh() { |
| + return !!(combine && combine.absorbedProof && combine.absorbedExpires - PROOF_MARGIN_MS > Date.now()); |
| +} |
| + |
| +// openCombineStep is the serial combine screen. Stage A (prove the other |
| +// account) when the absorbed side is still unproven; a proven side goes |
| +// straight to stage B (its spaces). The Apple prove is a full round trip |
| +// (merge-apple intent), so its return re-enters here already proven. |
| +function openCombineStep() { |
| + if (!combine) { backToMethodsList("", "", { reload: false }); return; } |
| + resetMethodsSteps(); |
| + showMethodsScreen("methods-combine"); |
| + $("combine-title").focus(); |
| + if (absorbedProofFresh()) { combineVaultStage(); return; } |
| + combine.absorbedProof = ""; // a stale proof re-proves from scratch |
| + $("combine-prove").classList.remove("hidden"); |
| + if (combine.kind === "email") { |
| + // The add-flow's code was spent by the 409 (the redeem happens before |
| + // the conflict answer), so a FRESH code proves the other side — say so. |
| + $("combine-prove-note").textContent = |
| + "First, prove the other account is yours: we’re sending another code to " + combine.email + "."; |
| + $("combine-code").classList.remove("hidden"); |
| + combineRequestCode(); |
| + $("combine-code-input").focus(); |
| + } else { |
| + $("combine-prove-note").textContent = |
| + "First, prove the other account is yours: sign in with that Apple ID — you’ll come straight back here."; |
| + $("combine-apple-go").classList.remove("hidden"); |
| + $("combine-apple-go").focus(); |
| + } |
| +} |
| + |
| +async function combineRequestCode() { |
| + combineStatus("Sending a code…", "busy"); |
| + try { |
| + await apiJSON("/api/email/request", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ email: combine.email }), |
| + }); |
| + combineStatus("We sent another code to prove that account is yours — check your email.", ""); |
| + } catch (e) { |
| + combineStatus((e && e.message) || "Couldn’t send the code — try again.", "error"); |
| + } |
| +} |
| + |
| +// The prove-by-code verify runs in SIGN-IN mode (link:false): the answer is |
| +// the absorbed account's token + proof, held in memory only (see combine). |
| +async function combineCodeVerify(e) { |
| + e.preventDefault(); |
| + const code = $("combine-code-input").value.trim(); |
| + const btn = $("combine-code-verify"); |
| + btn.disabled = true; |
| + btn.classList.add("loading"); |
| + combineStatus("Checking your code…", "busy"); |
| + const spinFloor = new Promise((r) => setTimeout(r, 900)); // same floor as the door |
| + try { |
| + const [res] = await Promise.all([apiJSON("/api/email/verify", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ email: combine.email, code }), |
| + }), spinFloor]); |
| + combine.absorbedToken = res.token; |
| + combine.absorbedProof = res.proof; |
| + combine.absorbedExpires = Date.now() + PROOF_TTL_MS; |
| + combineStatus(""); |
| + combineVaultStage(); |
| + } catch (err) { |
| + combineStatus((err && err.message) || "That code didn’t work — try again.", "error"); |
| + } finally { |
| + btn.disabled = false; |
| + btn.classList.remove("loading"); |
| + } |
| +} |
| + |
| +// The prove-by-Apple leg: a full round trip carrying the merge-apple intent; |
| +// finishSiwaMergeProve picks the return up and re-enters the step proven. |
| +function combineAppleProve(e) { |
| + startAppleSignIn(e, { intent: "merge-apple" }); |
| +} |
| + |
| +// Stage B: the absorbed account's saved spaces. Nothing to move (no blob) |
| +// skips straight to the merge; a real blob offers the honest choices — |
| +// unlock with ITS passkey and bring them over, or (behind an inline |
| +// are-you-sure) combine without them, destroying its sealed list. |
| +async function combineVaultStage() { |
| + $("combine-prove").classList.add("hidden"); |
| + $("combine-code").classList.add("hidden"); |
| + $("combine-apple-go").classList.add("hidden"); |
| + $("combine-skip-confirm").classList.add("hidden"); |
| + combineStatus("Checking the other account’s spaces…", "busy"); |
| + let got; |
| + try { |
| + got = await fetchBlob(combine.absorbedToken); |
| + if (got) { |
| + const r = await apiJSON("/api/anchors", { headers: { Authorization: "Bearer " + combine.absorbedToken } }); |
| + combine.absorbedPasskeyIds = (r.anchors || []).filter((x) => x.kind === "passkey").map((x) => x.id); |
| + } |
| + } catch (e) { |
| + combineStatus((e && e.message) || "Couldn’t reach the other account — try again.", "error"); |
| + return; |
| + } |
| + if (!got) { combineFinish(0); return; } // (a) nothing to move |
| + combine.absorbedBlob = got; |
| + combineStatus(""); |
| + const canCeremony = !!window.PublicKeyCredential && combine.absorbedPasskeyIds.length > 0; |
| + // The union needs the SURVIVOR side open too: its DEK in hand and at least |
| + // one wrapped copy of it to seal under (a keys-less PUT would mint a blob |
| + // nobody could ever open). |
| + const canUnion = !!dekKey && !!vault && Object.keys(vault.keys || {}).length > 0; |
| + $("combine-vault").classList.remove("hidden"); |
| + $("combine-unlock").classList.toggle("hidden", !(canCeremony && canUnion)); |
| + $("combine-skip").classList.remove("hidden"); |
| + const note = $("combine-vault-note"); |
| + if (canCeremony && canUnion) { |
| + note.textContent = "The other account has saved spaces. Unlock them with that account’s passkey and they’ll move over with everything else."; |
| + $("combine-unlock").focus(); |
| + prefetchLogin(); // the unlock ceremony wants gesture-fresh options |
| + } else if (!canUnion) { |
| + 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."; |
| + $("combine-skip").focus(); |
| + } else { |
| + note.textContent = "The other account has saved spaces, but it has no passkey to unlock them, so they can’t be brought over."; |
| + $("combine-skip").focus(); |
| + } |
| +} |
| + |
| +// combineUnlock runs the ABSORBED account's passkey ceremony, decrypts its |
| +// vault, unions it into the survivor's (survivor wins ties, tombstones from |
| +// either side hold, exactly saveVault's race rules), re-encrypts under the |
| +// survivor's DEK, deletes the absorbed blob, then merges. |
| +async function combineUnlock() { |
| + const btn = $("combine-unlock"); |
| + btn.disabled = true; |
| + combineStatus("Confirm with the OTHER account’s passkey…", "busy"); |
| + let fin; |
| + try { |
| + fin = await runLogin(loginBegin); |
| + } catch (e) { |
| + combineStatus(friendlyCeremonyError(e, "signin").message, "error"); |
| + prefetchLogin(); |
| + btn.disabled = false; |
| + return; |
| + } |
| + prefetchLogin(); // options were spent either way |
| + // The ceremony MUST land on the absorbed account — its credential ids are |
| + // exactly the passkey ids its anchors listing gave us. A miss means the |
| + // wrong passkey was picked (likely this account's own): explain and retry. |
| + if (!combine.absorbedPasskeyIds.includes(fin.credId)) { |
| + combineStatus("That passkey opens a different account — choose the OTHER account’s passkey and try again.", "error"); |
| + btn.disabled = false; |
| + return; |
| + } |
| + // Freshest absorbed credentials win (the ceremony just minted a proof). |
| + combine.absorbedToken = fin.token; |
| + combine.absorbedProof = fin.proof; |
| + combine.absorbedExpires = Date.now() + PROOF_TTL_MS; |
| + try { |
| + 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."); |
| + const kek = await kekFromPRF(fin.prf); |
| + const wrapped = unpackBlob(combine.absorbedBlob.bytes).keys[fin.credId]; |
| + if (!wrapped) throw new Error("That passkey can’t unlock the other account’s spaces."); |
| + const absDek = await unwrapDEK(wrapped, kek); |
| + const opened = await openBlob(combine.absorbedBlob.bytes, absDek); |
| + combineStatus("Bringing its spaces over…", "busy"); |
| + // Union under saveVault's rules: absorbed first, survivor overlays |
| + // (survivor wins ties by url); tombstones from either side hold unless |
| + // the SURVIVOR's live list still carries the entry. |
| + const before = (vault.obj.servers || []).length; |
| + const merged = new Map((opened.obj.servers || []).map((sv) => [sv.url, sv])); |
| + for (const sv of vault.obj.servers || []) merged.set(sv.url, sv); |
| + const removed = mergeRemoved(opened.obj.removed, vault.obj.removed) |
| + .filter((t) => !(vault.obj.servers || []).some((sv) => sv.url === t.url)); |
| + vault.obj.servers = [...merged.values()].filter((sv) => !removed.some((t) => t.url === sv.url)); |
| + if (removed.length) vault.obj.removed = removed; else delete vault.obj.removed; |
| + const movedSpaces = vault.obj.servers.length - before; |
| + // The absorbed passkey just proved AND unlocked — after the merge it |
| + // signs into the survivor, so wrap the survivor's DEK for it too: it |
| + // should OPEN the combined list, not hit the sealed prompt. |
| + vault.keys[fin.credId] = await wrapDEK(dekKey, kek); |
| + await saveVault(); // CAS PUT of the survivor's union, retrying once |
| + await deleteAbsorbedBlob(); // client-first ordering: emptied before merge |
| + combineFinish(movedSpaces); |
| + } catch (e) { |
| + combineStatus((e && e.message) || "Couldn’t bring its spaces over.", "error"); |
| + btn.disabled = false; |
| + } |
| +} |
| + |
| +// The skip path's inline are-you-sure: destroying the absorbed account's |
| +// sealed spaces list must be an explicit, plainly-worded beat of its own. |
| +function combineSkip() { |
| + $("combine-unlock").classList.add("hidden"); |
| + $("combine-skip").classList.add("hidden"); |
| + $("combine-skip-confirm").classList.remove("hidden"); |
| + $("combine-skip-yes").focus(); |
| +} |
| + |
| +async function combineSkipYes() { |
| + const btn = $("combine-skip-yes"); |
| + btn.disabled = true; |
| + combineStatus("Clearing its spaces list…", "busy"); |
| + try { |
| + await deleteAbsorbedBlob(); |
| + combineFinish(0); |
| + } catch (e) { |
| + combineStatus((e && e.message) || "Couldn’t clear the other account’s backup.", "error"); |
| + } finally { |
| + btn.disabled = false; |
| + } |
| +} |
| + |
| +// deleteAbsorbedBlob is the ceremony-gated DELETE /api/blob, authed by the |
| +// ABSORBED side's proof — merge.go's precondition (client-first ordering). |
| +async function deleteAbsorbedBlob() { |
| + if (!absorbedProofFresh()) { |
| + openCombineStep(); // stale other-side proof: prove again, then retry |
| + combineStatus("That took a while — prove the other account again first.", "error"); |
| + throw new Error("prove the other account again first"); |
| + } |
| + const res = await fetch("/api/blob", { method: "DELETE", headers: { "X-Ceremony-Proof": combine.absorbedProof } }); |
| + if (!res.ok && res.status !== 404) { |
| + throw new Error(((await res.text().catch(() => "")) || "").trim() || "Couldn’t clear the other account’s backup."); |
| + } |
| +} |
| + |
| +// Stage C: the merge itself — both sides' fresh proofs (merge.go's |
| +// both-sides-proofs auth), then the plain-words outcome back on the list. |
| +async function combineFinish(movedSpaces = 0) { |
| + if (!combine) { backToMethodsList("", "", { reload: false }); return; } |
| + const sp = freshProof(); |
| + if (!sp) { |
| + // The SURVIVOR's proof lapsed mid-combine: the confirm step runs, then |
| + // finishes right here (memory-only intent — a confirm-with-Apple trip |
| + // would lose the combine context, and intentToken serializes this to |
| + // nothing on purpose). |
| + pendingIntent = { kind: "combine-finish", movedSpaces }; |
| + openMethodsConfirmStep("That took a while — confirm it’s you again to finish combining."); |
| + return; |
| + } |
| + if (!absorbedProofFresh()) { |
| + openCombineStep(); |
| + combineStatus("That took a while — prove the other account again first.", "error"); |
| + return; |
| + } |
| + showMethodsScreen("methods-combine"); |
| + combineStatus("Combining the accounts…", "busy"); |
| + let res; |
| + try { |
| + res = await fetch("/api/account/merge", { |
| + method: "POST", |
| + headers: { |
| + Authorization: "Bearer " + state.token, |
| + "X-Ceremony-Proof": sp, |
| + "Content-Type": "application/json", |
| + }, |
| + body: JSON.stringify({ absorb_proof: combine.absorbedProof }), |
| + }); |
| + } catch { |
| + combineStatus("Couldn’t reach the server — try again.", "error"); |
| + return; |
| + } |
| + if (!res.ok) { |
| + // The server's plain refusals verbatim (same-account, still-holds-a- |
| + // backup, a stale proof), landed on this step for another go. |
| + combineStatus(((await res.text().catch(() => "")) || "").trim() || "Couldn’t combine the accounts.", "error"); |
| + return; |
| + } |
| + const body = await res.json().catch(() => ({})); |
| + const movedMethods = (body.moved && (body.moved.anchors || 0) + (body.moved.passkeys || 0)) || 0; |
| + let msg = "Accounts combined — " + movedMethods.toLocaleString() + " sign-in method" + |
| + (movedMethods === 1 ? "" : "s") + " moved"; |
| + if (movedSpaces > 0) { |
| + msg += ", " + movedSpaces.toLocaleString() + " space" + (movedSpaces === 1 ? "" : "s") + " brought over"; |
| + } |
| + msg += "."; |
| + combine = null; |
| + backToMethodsList(msg, "ok"); // reloads the anchors — the moved methods appear |
| + if (movedSpaces > 0 && vault) provisioned = null; // the ledger may know the new spaces |
| +} |
| + |
| // ---- spaces list -------------------------------------------------------------- |
| |
| // hideVault parks the spaces screen for a sibling screen (or sign-out). The |
| @@ -2027,6 +3233,7 @@ async function destroyForEveryone() { |
| let proof; |
| try { |
| ({ proof } = await runLogin(loginBegin)); |
| + noteProof(proof); // fills the confirm-step pocket (Sign-in options) |
| } catch (e) { |
| prefetchLogin(); |
| archiveStatus(friendlyCeremonyError(e, "signin").message, "error"); |
| @@ -2137,8 +3344,19 @@ let siwaBooting = false; // claims the boot — the cold-open path below stands |
| history.replaceState(null, "", location.pathname + location.search); |
| const minted = sessionStorage.getItem("home_siwa_state") || ""; |
| sessionStorage.removeItem("home_siwa_state"); |
| + // The methods step's round-trip markers (add-Apple's stashed proof, the |
| + // confirm-with-Apple return flag, the serialized in-progress intent) — |
| + // consumed exactly once, here, so a reload can't replay any of them. |
| + const linkMark = sessionStorage.getItem("home_siwa_link"); |
| + const methodsReturn = !!sessionStorage.getItem("home_methods_return"); |
| + const methodsIntent = sessionStorage.getItem("home_methods_intent") || ""; |
| + sessionStorage.removeItem("home_siwa_link"); |
| + sessionStorage.removeItem("home_methods_return"); |
| + sessionStorage.removeItem("home_methods_intent"); |
| if (minted && decodeURIComponent(m[2]) === minted) { |
| - finishSiwa(decodeURIComponent(m[1])); |
| + if (linkMark) finishSiwaLink(decodeURIComponent(m[1]), linkMark); |
| + else if (methodsIntent === "merge-apple") finishSiwaMergeProve(decodeURIComponent(m[1])); |
| + else finishSiwa(decodeURIComponent(m[1]), methodsReturn, methodsIntent); |
| } else { |
| // A state that didn't start here: straight to the signed-out card. |
| finishHomeLoader({ immediate: true }); |
| @@ -2174,7 +3392,48 @@ $("account-menu-btn").addEventListener("pointerdown", () => { kebabPointerOpen = |
| $("account-menu-btn").addEventListener("click", () => { |
| if (accountMenuOpen()) closeAccountMenu(true); else openAccountMenu(); |
| }); |
| -$("signout").addEventListener("click", signOut); |
| +// Sign out is technically instant (local state clears) — the ~700ms beat |
| +// makes it read as the event it is (Paul's rule: meaningful transitions |
| +// earn a working state; case-by-case, not universal). |
| +$("signout").addEventListener("click", () => { |
| + closeAccountMenu(false); |
| + const card = document.querySelector("main.card"); |
| + card.classList.add("waiting"); |
| + setTimeout(() => { |
| + card.classList.remove("waiting"); |
| + signOut(); |
| + }, 700); |
| +}); |
| +// Sign-in options (issue #1098): the kebab's serial step family. The list's |
| +// own controls (row kebabs, first-add buttons) are wired per render. |
| +$("how-signin").addEventListener("click", () => { closeAccountMenu(false); openMethods(); }); |
| +$("methods-back").addEventListener("click", closeMethods); |
| +$("methods-confirm-back").addEventListener("click", () => { |
| + pendingIntent = null; // backing out cancels the parked change |
| + backToMethodsList("", "", { reload: false }); |
| +}); |
| +$("methods-confirm-passkey").addEventListener("click", confirmMethodsPasskey); |
| +$("methods-confirm-apple").addEventListener("click", confirmMethodsApple); |
| +$("methods-cemail-form").addEventListener("submit", cemailSend); |
| +$("methods-cemail-code-form").addEventListener("submit", cemailVerify); |
| +$("methods-email-back").addEventListener("click", () => backToMethodsList("", "", { reload: false })); |
| +$("methods-email-form").addEventListener("submit", methodsEmailSend); |
| +$("methods-email-code-form").addEventListener("submit", methodsEmailVerify); |
| +$("methods-passkey-back").addEventListener("click", () => backToMethodsList("", "", { reload: false })); |
| +$("methods-passkey-create").addEventListener("click", addPasskeyCeremony); |
| +// The combine step (#1098's merge): offer → prove → its spaces → the merge. |
| +$("combine-offer-go").addEventListener("click", openCombineStep); |
| +$("combine-back").addEventListener("click", () => { |
| + combine = null; // cancel leaves both accounts untouched |
| + backToMethodsList("", "", { reload: false }); |
| +}); |
| +$("combine-code-form").addEventListener("submit", combineCodeVerify); |
| +$("combine-resend").addEventListener("click", combineRequestCode); |
| +$("combine-apple-go").addEventListener("click", combineAppleProve); |
| +$("combine-unlock").addEventListener("click", combineUnlock); |
| +$("combine-skip").addEventListener("click", combineSkip); |
| +$("combine-skip-yes").addEventListener("click", combineSkipYes); |
| +$("combine-skip-no").addEventListener("click", combineVaultStage); |
| $("open-create").addEventListener("click", openCreate); |
| $("create-back").addEventListener("click", closeCreate); |
| $("create-done").addEventListener("click", closeCreate); |