| index afe9a3b..85be3c8 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -741,7 +741,7 @@ 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; |
| + let outcome, conflict = false; |
| if (!mark || !mark.proof || !(mark.expires > Date.now())) { |
| outcome = { msg: "That took a little too long — confirm it's you again, then tap Add " + p.display + ".", kind: "error" }; |
| } else { |
| @@ -755,6 +755,7 @@ async function finishOidcLink(rec, cred, markRaw) { |
| 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; |
| outcome = { msg: msg || "Couldn't add " + p.display + " — try again.", kind: "error" }; |
| } |
| } catch { |
| @@ -764,6 +765,10 @@ async function finishOidcLink(rec, cred, markRaw) { |
| if (await resumeAfterLinkReturn()) { |
| await openMethods(); |
| methodsStatus(outcome.msg, outcome.kind); |
| + if (conflict) { |
| + combine = { kind: rec.provider }; |
| + showCombineOffer("methods-status"); |
| + } |
| } else { |
| setStatus(outcome.msg, outcome.kind === "ok" ? "" : "error"); |
| } |
| @@ -801,6 +806,36 @@ async function finishSiwaMergeProve(idToken) { |
| finishHomeLoader({ immediate: true }); |
| } |
| |
| +// finishOidcMergeProve — finishSiwaMergeProve's provider twin: the combine |
| +// step sent the person to Google/Microsoft/GitHub to prove the OTHER |
| +// account; the answer is held for the merge, never adopted. |
| +async function finishOidcMergeProve(rec, cred) { |
| + const p = OIDC_WEB[rec.provider]; |
| + let creds = null; |
| + try { |
| + const res = await apiJSON(p.verify, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify(oidcPayload(p, cred, rec)), |
| + }); |
| + 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 " + p.display + " sign-in couldn't be confirmed — try combining again.", "error"); |
| + finishHomeLoader({ immediate: true }); |
| + return; |
| + } |
| + combine = { kind: rec.provider, 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 |
| @@ -1158,12 +1193,12 @@ const METHOD_KINDS = [ |
| enabled: () => !!window.PublicKeyCredential, start: (e) => startAddPasskey(e) }, |
| { kind: "siwa", name: "Apple", first: "Add Apple", |
| enabled: () => DOORS.apple, start: (e) => startAddApple(e) }, |
| - { kind: "google", name: "Google", first: "Add Google", |
| - enabled: () => DOORS.google, start: (e) => startAddGoogle(e) }, |
| - { kind: "microsoft", name: "Microsoft", first: "Add Microsoft", |
| - enabled: () => DOORS.microsoft, start: (e) => startAddOidc("microsoft", e) }, |
| { kind: "github", name: "GitHub", first: "Add GitHub", |
| enabled: () => DOORS.github, start: (e) => startAddOidc("github", e) }, |
| + { kind: "microsoft", name: "Microsoft", first: "Add Microsoft", |
| + enabled: () => DOORS.microsoft, start: (e) => startAddOidc("microsoft", e) }, |
| + { kind: "google", name: "Google", first: "Add Google", |
| + enabled: () => DOORS.google, start: (e) => startAddGoogle(e) }, |
| { kind: "email", name: "Email", first: "Add email", |
| enabled: () => DOORS.email, start: (e) => startAddEmail(e) }, |
| ]; |
| @@ -1332,6 +1367,7 @@ function resetMethodsSteps() { |
| $("combine-prove").classList.add("hidden"); |
| $("combine-code").classList.add("hidden"); |
| $("combine-apple-go").classList.add("hidden"); |
| + $("combine-oidc-go").classList.add("hidden"); |
| $("combine-code-input").value = ""; |
| $("combine-vault").classList.add("hidden"); |
| $("combine-unlock").classList.add("hidden"); |
| @@ -1426,6 +1462,8 @@ function renderMethodsAdd() { |
| btn.type = "button"; |
| btn.className = "secondary"; |
| btn.textContent = k.first; |
| + const glyph = kindGlyph(k.kind); |
| + if (glyph) btn.prepend(glyph); |
| btn.addEventListener("click", (e) => k.start(e)); |
| box.append(btn); |
| } |
| @@ -2041,11 +2079,23 @@ function openCombineStep() { |
| $("combine-code").classList.remove("hidden"); |
| combineRequestCode(); |
| $("combine-code-input").focus(); |
| - } else { |
| +} else if (combine.kind === "apple") { |
| $("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(); |
| + } else { |
| + // A provider kind (google/microsoft/github): one generic go button, |
| + // dressed per provider — brand class, display name, cloned glyph. |
| + const p = OIDC_WEB[combine.kind]; |
| + $("combine-prove-note").textContent = |
| + "First, prove the other account is yours: sign in with that " + p.display + " account — you’ll come straight back here."; |
| + const go = $("combine-oidc-go"); |
| + go.className = { google: "google-btn", microsoft: "ms-btn", github: "gh-btn" }[combine.kind]; |
| + go.textContent = "Continue with " + p.display; |
| + const glyph = kindGlyph(combine.kind); |
| + if (glyph) go.prepend(glyph); |
| + go.focus(); |
| } |
| } |
| |
| @@ -2098,6 +2148,18 @@ function combineAppleProve(e) { |
| startAppleSignIn(e, { intent: "merge-apple" }); |
| } |
| |
| +// kindGlyph clones a kind's brand mark from its all-ways button — one |
| +// source per glyph (static, in-code SVG), sized for inline button use. |
| +function kindGlyph(kind) { |
| + const src = $("otherways-" + (kind === "siwa" ? "apple" : kind)); |
| + const svg = src && src.querySelector("svg"); |
| + if (!svg) return null; |
| + const c = svg.cloneNode(true); |
| + c.classList.remove("pk-ico"); |
| + c.classList.add("btn-ico"); |
| + return c; |
| +} |
| + |
| // 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 |
| @@ -2106,6 +2168,7 @@ async function combineVaultStage() { |
| $("combine-prove").classList.add("hidden"); |
| $("combine-code").classList.add("hidden"); |
| $("combine-apple-go").classList.add("hidden"); |
| + $("combine-oidc-go").classList.add("hidden"); |
| $("combine-skip-confirm").classList.add("hidden"); |
| combineStatus("Checking the other account’s spaces…", "busy"); |
| let got; |
| @@ -3672,6 +3735,9 @@ $("otherways-apple").addEventListener("click", startAppleSignIn); |
| $("otherways-google").addEventListener("click", (e) => startOidcSignIn("google", e)); |
| $("otherways-microsoft").addEventListener("click", (e) => startOidcSignIn("microsoft", e)); |
| $("otherways-github").addEventListener("click", (e) => startOidcSignIn("github", e)); |
| +$("combine-oidc-go").addEventListener("click", (e) => { |
| + if (combine && OIDC_WEB[combine.kind]) startOidcSignIn(combine.kind, e, { intent: "merge-" + combine.kind }); |
| +}); |
| $("otherways-email").addEventListener("click", openEmailDoor); |
| $("otherways-passkey").addEventListener("click", () => signIn().catch(onSignInError)); |
| |
| @@ -3731,6 +3797,7 @@ if (!siwaBooting) { |
| sessionStorage.removeItem("home_methods_intent"); |
| 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 { |
| finishHomeLoader({ immediate: true }); |
| @@ -3758,6 +3825,7 @@ if (!siwaBooting && !oidcBooting) { |
| sessionStorage.removeItem("home_methods_intent"); |
| 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 { |
| finishHomeLoader({ immediate: true }); |