| index d97f805..bbfe0e8 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -570,6 +570,7 @@ async function finishSiwaLink(idToken, markRaw) { |
| let outcome, conflict = false; |
| if (!mark || !mark.proof || !(mark.expires > Date.now())) { |
| // The proof's 5-minute window lapsed during the trip — re-prompt. |
| + dropProof(); // the pocket mirrors the same lapsed proof |
| outcome = { msg: "That took a little too long — confirm it's you again, then tap Add Apple.", kind: "error" }; |
| } else { |
| try { |
| @@ -579,6 +580,9 @@ async function finishSiwaLink(idToken, markRaw) { |
| body: JSON.stringify({ identity_token: idToken, link: true }), |
| }); |
| if (res.ok) { |
| + // The proof just verified — good for the window's remainder, so a |
| + // chained second add skips the confirm step. |
| + noteProof(mark.proof, mark.expires); |
| outcome = { msg: "Apple is now a way into your account.", kind: "ok" }; |
| } else { |
| // The server's refusals verbatim: a lapsed proof (401 — the line |
| @@ -587,6 +591,9 @@ async function finishSiwaLink(idToken, markRaw) { |
| // the status, never off message text. |
| const msg = ((await res.text().catch(() => "")) || "").trim(); |
| conflict = res.status === 409; |
| + // A 409 still verified the proof (only the identity clashed); any |
| + // other refusal is almost always the lapsed proof — re-confirm next. |
| + if (conflict) noteProof(mark.proof, mark.expires); else dropProof(); |
| outcome = { msg: msg || "Couldn't add Apple — try again.", kind: "error" }; |
| } |
| } catch { |
| @@ -683,7 +690,8 @@ function startOidcSignIn(provider, e, opts = {}) { |
| try { |
| if (opts.linkProof) sessionStorage.setItem("home_siwa_link", JSON.stringify(opts.linkProof)); |
| else sessionStorage.removeItem("home_siwa_link"); |
| - sessionStorage.removeItem("home_methods_return"); |
| + 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 */ } |
| @@ -736,15 +744,16 @@ async function finishOidc(rec, cred, backToMethods = false, intentStr = "") { |
| } |
| |
| // finishOidcLink — finishSiwaLink's twin: the ADD round trip (link:true + |
| -// the stashed proof). The 409's combine offer is deliberately NOT wired for |
| -// OIDC providers yet — the combine prove step speaks Apple and email today; |
| -// the refusal shows verbatim and combining goes via those doors. |
| +// the stashed proof). The 409 grows the same combine offer as Apple's leg — |
| +// the prove step sends the person back through the clashing provider's own |
| +// door (combine-oidc-go → the merge-<provider> intent). |
| async function finishOidcLink(rec, cred, markRaw) { |
| const p = OIDC_WEB[rec.provider]; |
| 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())) { |
| + dropProof(); // the pocket mirrors the same lapsed proof |
| outcome = { msg: "That took a little too long — confirm it's you again, then tap Add " + p.display + ".", kind: "error" }; |
| } else { |
| try { |
| @@ -754,10 +763,16 @@ async function finishOidcLink(rec, cred, markRaw) { |
| body: JSON.stringify({ ...oidcPayload(p, cred, rec), link: true }), |
| }); |
| if (res.ok) { |
| + // The proof just verified — good for the window's remainder, so a |
| + // chained second add skips the confirm step. |
| + noteProof(mark.proof, mark.expires); |
| outcome = { msg: p.display + " is now a way into your account.", kind: "ok" }; |
| } else { |
| const msg = ((await res.text().catch(() => "")) || "").trim(); |
| conflict = res.status === 409; |
| + // A 409 still verified the proof (only the identity clashed); any |
| + // other refusal is almost always the lapsed proof — re-confirm next. |
| + if (conflict) noteProof(mark.proof, mark.expires); else dropProof(); |
| outcome = { msg: msg || "Couldn't add " + p.display + " — try again.", kind: "error" }; |
| } |
| } catch { |
| @@ -1104,7 +1119,7 @@ function signOut() { |
| combine = null; |
| anchors = null; |
| armedRemove = ""; |
| - ceremonyProof = null; // proofs are per-person, never per-browser |
| + dropProof(); // proofs are per-person, never per-browser |
| archiving = null; |
| clearUndo(); |
| $("spaces").textContent = ""; |
| @@ -1172,14 +1187,27 @@ function accountMenuKey(e) { |
| // 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. |
| +// arrival. The pocket mirrors into sessionStorage — same tab, like the |
| +// round-trip markers — because adding Apple or an OIDC provider IS a |
| +// full-page navigation: without the mirror, every return leg came back to |
| +// an empty pocket and the next change re-demanded a ceremony inside the |
| +// same 5-minute window. Sign-out drops it (proofs are per-person). |
| 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 noteProof(proof, expires) { |
| + if (!proof) return; |
| + ceremonyProof = { proof, expires: expires || Date.now() + PROOF_TTL_MS }; |
| + try { sessionStorage.setItem("home_proof", JSON.stringify(ceremonyProof)); } catch { /* memory still holds it */ } |
| +} |
| +function dropProof() { |
| + ceremonyProof = null; |
| + try { sessionStorage.removeItem("home_proof"); } catch { /* nothing stored */ } |
| } |
| function freshProof() { |
| + if (!ceremonyProof) { |
| + try { ceremonyProof = JSON.parse(sessionStorage.getItem("home_proof") || "null"); } catch { /* mangled — an empty pocket */ } |
| + } |
| return ceremonyProof && ceremonyProof.expires - PROOF_MARGIN_MS > Date.now() |
| ? ceremonyProof.proof : null; |
| } |
| @@ -1348,6 +1376,10 @@ function resetMethodsSteps() { |
| // The confirm step. |
| $("methods-confirm-passkey").classList.add("hidden"); |
| $("methods-confirm-apple").classList.add("hidden"); |
| + $("methods-confirm-google").classList.add("hidden"); |
| + $("methods-confirm-microsoft").classList.add("hidden"); |
| + $("methods-confirm-github").classList.add("hidden"); |
| + $("methods-confirm-email").classList.add("hidden"); |
| $("methods-cemail").classList.add("hidden"); |
| $("methods-cemail-form").classList.remove("hidden"); |
| $("methods-cemail-code-form").classList.add("hidden"); |
| @@ -1720,33 +1752,65 @@ function resumeIntent() { |
| |
| // ---- the confirm step ----------------------------------------------------------- |
| |
| +// The confirm rungs, in fallback order; last-used (the #1112 marker) is |
| +// promoted to the front. Each rung answers "can this account actually run |
| +// it" from the anchors listing — the server takes a fresh proof from ANY |
| +// door equally (stateless.go's ceremonyUser), so the step offers every one |
| +// the account has, never passkey-or-bust. Apple doubles as the fallback for |
| +// an account whose listing hasn't loaded (the pre-anchors shape). |
| +const CONFIRM_RUNGS = ["passkey", "apple", "google", "microsoft", "github", "email"]; |
| +function confirmRungAvailable(rung) { |
| + const kinds = new Set((anchors || []).map((x) => x.kind)); |
| + if (rung === "passkey") return !!window.PublicKeyCredential && kinds.has("passkey"); |
| + if (rung === "apple") return DOORS.apple && kinds.has("siwa"); |
| + // google | microsoft | github | email — anchor kinds verbatim, gated by the |
| + // door being LIVE on this build (a dark door can't run its ceremony). |
| + return DOORS[rung] && kinds.has(rung); |
| +} |
| + |
| // 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. |
| +// whenever an action needs a proof the pocket doesn't hold. Every door the |
| +// account has shows, last-used first; the email rung unfolds into its code |
| +// mini-form (immediately when it's the only rung — no pointless extra tap). |
| 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); |
| + let rungs = CONFIRM_RUNGS.filter(confirmRungAvailable); |
| + if (!rungs.length) rungs = ["apple"]; |
| + let last = ""; |
| + try { last = localStorage.getItem("home_last_signin") || ""; } catch { /* fallback order stands */ } |
| + if (rungs.includes(last)) rungs = [last, ...rungs.filter((r) => r !== last)]; |
| + const btns = { |
| + passkey: $("methods-confirm-passkey"), apple: $("methods-confirm-apple"), |
| + google: $("methods-confirm-google"), microsoft: $("methods-confirm-microsoft"), |
| + github: $("methods-confirm-github"), email: $("methods-confirm-email"), |
| + }; |
| + for (const [rung, btn] of Object.entries(btns)) btn.classList.toggle("hidden", !rungs.includes(rung)); |
| + // DOM order is focus order — append MOVES the wired nodes into rung order. |
| + for (const rung of rungs) $("methods-confirm-actions").append(btns[rung]); |
| 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) { |
| + if (rungs.length === 1 && rungs[0] === "email") { |
| + btns.email.classList.add("hidden"); // the one rung — straight to its form |
| 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(); |
| + why.textContent = rungs.length === 1 |
| + ? "Changing your sign-in options needs a quick check — confirm it's you and you'll carry straight on." |
| + : "Changing your sign-in options needs a quick check — confirm it's you with any of these and you'll carry straight on."; |
| + btns[rungs[0]].focus(); |
| } |
| } |
| |
| +// The email rung's tap unfolds the code mini-form in place — the other |
| +// rungs stay, so a mind-change needs no back-and-forth. |
| +function confirmMethodsEmail() { |
| + $("methods-confirm-email").classList.add("hidden"); |
| + $("methods-cemail").classList.remove("hidden"); |
| + $("methods-cemail-addr").focus(); |
| +} |
| + |
| async function confirmMethodsPasskey() { |
| const btn = $("methods-confirm-passkey"); |
| btn.disabled = true; |
| @@ -1776,6 +1840,16 @@ function confirmMethodsApple(e) { |
| startAppleSignIn(e, { methodsReturn: true, intent }); |
| } |
| |
| +// confirmMethodsOidc — the Apple confirm through an OIDC web door: the same |
| +// marker-routed round trip (finishOidc pockets the proof, methodsReturn |
| +// lands back on the step family, the intent resumes). select_account so a |
| +// person with several provider accounts can pick the linked one. |
| +function confirmMethodsOidc(provider, e) { |
| + const intent = intentToken(pendingIntent); |
| + pendingIntent = null; // payloads can't survive the navigation; the token rides |
| + startOidcSignIn(provider, e, { methodsReturn: true, intent, select: true }); |
| +} |
| + |
| // 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. |
| @@ -3793,6 +3867,7 @@ if (!siwaBooting) { |
| try { rec = JSON.parse(sessionStorage.getItem("home_oidc_state") || "null"); } catch { /* mangled */ } |
| sessionStorage.removeItem("home_oidc_state"); |
| 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"); |
| @@ -3800,7 +3875,7 @@ if (!siwaBooting) { |
| if (rec && rec.state && OIDC_WEB[rec.provider] && decodeURIComponent(m[2]) === rec.state) { |
| if (linkMark) finishOidcLink(rec, decodeURIComponent(m[1]), linkMark); |
| else if (methodsIntent === "merge-" + rec.provider) finishOidcMergeProve(rec, decodeURIComponent(m[1])); |
| - else finishOidc(rec, decodeURIComponent(m[1]), false, methodsIntent); |
| + else finishOidc(rec, decodeURIComponent(m[1]), methodsReturn, methodsIntent); |
| } else { |
| finishHomeLoader({ immediate: true }); |
| setStatus("That sign-in didn’t start here — try again.", "error"); |
| @@ -3821,6 +3896,7 @@ if (!siwaBooting && !oidcBooting) { |
| try { rec = JSON.parse(sessionStorage.getItem("home_oidc_state") || "null"); } catch { /* mangled */ } |
| sessionStorage.removeItem("home_oidc_state"); |
| 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"); |
| @@ -3828,7 +3904,7 @@ if (!siwaBooting && !oidcBooting) { |
| if (rec && rec.state && OIDC_WEB[rec.provider] && decodeURIComponent(m[2]) === rec.state) { |
| if (linkMark) finishOidcLink(rec, decodeURIComponent(m[1]), linkMark); |
| else if (methodsIntent === "merge-" + rec.provider) finishOidcMergeProve(rec, decodeURIComponent(m[1])); |
| - else finishOidc(rec, decodeURIComponent(m[1]), false, methodsIntent); |
| + else finishOidc(rec, decodeURIComponent(m[1]), methodsReturn, methodsIntent); |
| } else { |
| finishHomeLoader({ immediate: true }); |
| setStatus("That sign-in didn’t start here — try again.", "error"); |
| @@ -3885,6 +3961,10 @@ $("methods-confirm-back").addEventListener("click", () => { |
| }); |
| $("methods-confirm-passkey").addEventListener("click", confirmMethodsPasskey); |
| $("methods-confirm-apple").addEventListener("click", confirmMethodsApple); |
| +$("methods-confirm-google").addEventListener("click", (e) => confirmMethodsOidc("google", e)); |
| +$("methods-confirm-microsoft").addEventListener("click", (e) => confirmMethodsOidc("microsoft", e)); |
| +$("methods-confirm-github").addEventListener("click", (e) => confirmMethodsOidc("github", e)); |
| +$("methods-confirm-email").addEventListener("click", confirmMethodsEmail); |
| $("methods-cemail-form").addEventListener("submit", cemailSend); |
| $("methods-cemail-code-form").addEventListener("submit", cemailVerify); |
| $("methods-email-back").addEventListener("click", () => backToMethodsList("", "", { reload: false })); |