eleven / messenger Public

home: combine offer for provider 409s + branded add buttons + Paul's order (#1163)

The GitHub/Google/Microsoft add-conflict now grows the same combine
doorway as Apple and email: a generic prove button dressed per provider
(brand class + cloned glyph), the merge-<provider> intent through the
ordinary door, and finishOidcMergeProve holding the absorbed side's
proof. First-add buttons clone their brand glyphs from the all-ways
buttons (one source per mark), and the provider order everywhere is
Apple, GitHub, Microsoft, Google.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell cae12a0c1e5cfc9333862030bbc98aa1cfd592fc parent cb512e1
2 files changed, +78 −9
  • home/web/pages/index.html +4 −3
  • home/web/static/home.js +74 −6
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 5a1d135..c1132a6 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -46,7 +46,7 @@
trap .icon-add.hidden answers below). Demoted doors hide this way. */
.apple-btn.hidden { display: none; }
/* The passkey mark rides inline in non-flex buttons: baseline-tuck + gap. */
- .pk-ico { vertical-align: -2.5px; margin-right: 7px; }
+ .pk-ico, .btn-ico { vertical-align: -2.5px; margin-right: 7px; }
/* Google's door, to their light-button spec: white, hairline border, the
official four-colour G (inline SVG — their rendered button needs their
JS, which the CSP rightly blocks; hand-built like Apple's). */
@@ -873,9 +873,9 @@
<div class="path-divider">For those oppressed by convenience…</div>
<div class="actions">
<button id="otherways-apple" class="apple-btn" type="button"><svg viewBox="0 0 814 1000" width="14" height="17" fill="currentColor" aria-hidden="true"><path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/></svg>Continue with Apple</button>
- <button id="otherways-google" class="google-btn" type="button"><svg viewBox="0 0 48 48" width="16" height="16" aria-hidden="true"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>Continue with Google</button>
- <button id="otherways-microsoft" class="ms-btn" type="button"><svg viewBox="0 0 21 21" width="15" height="15" aria-hidden="true"><rect x="1" y="1" width="9" height="9" fill="#F25022"/><rect x="11" y="1" width="9" height="9" fill="#7FBA00"/><rect x="1" y="11" width="9" height="9" fill="#00A4EF"/><rect x="11" y="11" width="9" height="9" fill="#FFB900"/></svg>Continue with Microsoft</button>
<button id="otherways-github" class="gh-btn" type="button"><svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>Continue with GitHub</button>
+ <button id="otherways-microsoft" class="ms-btn" type="button"><svg viewBox="0 0 21 21" width="15" height="15" aria-hidden="true"><rect x="1" y="1" width="9" height="9" fill="#F25022"/><rect x="11" y="1" width="9" height="9" fill="#7FBA00"/><rect x="1" y="11" width="9" height="9" fill="#00A4EF"/><rect x="11" y="11" width="9" height="9" fill="#FFB900"/></svg>Continue with Microsoft</button>
+ <button id="otherways-google" class="google-btn" type="button"><svg viewBox="0 0 48 48" width="16" height="16" aria-hidden="true"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>Continue with Google</button>
</div>
</div>
@@ -1029,6 +1029,7 @@
<button id="combine-resend" class="passkey-link" type="button">Send a new code</button>
</div>
<div class="actions">
+ <button id="combine-oidc-go" class="hidden" type="button"></button>
<button id="combine-apple-go" class="apple-btn hidden" type="button"><svg viewBox="0 0 814 1000" width="14" height="17" fill="currentColor" aria-hidden="true"><path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/></svg>Continue with Apple</button>
</div>
</div>
diff --git a/home/web/static/home.js b/home/web/static/home.js
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 });