home: sign in first — both paths up front, friendly ceremony errors (#718)
People were getting dumped into a passkey picker they didn't understand,
cancelling it, and reading an error before the create path appeared. Now
both intents are visible up front on the home landing and the /enroll
page — "Sign in" primary, the create path as a visually separated
secondary under a "New here?" divider — so nobody has to fail a ceremony
to find their path.
- /enroll: "Sign in and save" (#save) + "I'm new — create my sign-in"
(#create-new) up front; the after-failure offerCreate stays as the
fallback, reworded in sign-in language ("Create my sign-in",
#create-fallback). Both buttons ride the prefetched ceremony options
(regBegin + loginBegin), preserving the Safari transient-activation
pattern; the enroll-done completion oracle POST is untouched.
- Home landing: "Create your sign-in" (#create-passkey) sits under Sign
in as a quieter secondary; the #newhere panel becomes explainer-only
reinforcement after a failed sign-in. Register options prefetch at
boot; both buttons disable together while a ceremony runs.
- New pure helper home/web/static/ceremony-errors.js maps WebAuthn
failures (name + phase) to specific, friendly copy — cancel, duplicate
registration, no screen lock, misconfigured RP — keeping the technical
name+phase crumb parenthesised for debugging. Used by enroll.js,
home.js, and restore.js; unit-tested in home/test/ceremony-errors.test.mjs.
- Language sweep to the "Sign in (with Eleven)" vocabulary — no more
"backup"/"passkey" on these surfaces — including the instance-side
nudge banner (web/static/backup.js): "Keep your access safe" / "Sign
in". The escalated dismiss copy stays.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
11 files changed,
+307
−124
changelog/2026-07-11-sign-in-first.md+8 −0home/test/ceremony-errors.test.mjs+88 −0home/web/pages/enroll.html+12 −6home/web/pages/index.html+21 −21home/web/static/ceremony-errors.js+59 −0home/web/static/enroll.js+41 −42home/web/static/home.css+12 −0home/web/static/home.js+57 −45home/web/static/restore.js+4 −5package.json+1 −1web/static/backup.js+4 −4
diff --git a/changelog/2026-07-11-sign-in-first.md b/changelog/2026-07-11-sign-in-first.md| new file mode 100644 |
| index 0000000..05fcddd |
| --- /dev/null |
| +++ b/changelog/2026-07-11-sign-in-first.md |
| @@ -0,0 +1,8 @@ |
| +🤖 Signing in with Eleven got clearer. Wherever you're asked to sign in — the |
| +Eleven home page and the "keep your access to this chat" screen — both paths |
| +now sit up front: **Sign in** if you already have one, and a separate |
| +**I'm new — create my sign-in** if you don't, so nobody lands in a prompt they |
| +didn't expect or has to cancel their way to the right choice. And when |
| +something does go wrong, the message now says what actually happened — "Sign |
| +in was cancelled", "That's already set up — try signing in instead" — instead |
| +of a cryptic error code. |
diff --git a/home/test/ceremony-errors.test.mjs b/home/test/ceremony-errors.test.mjs| new file mode 100644 |
| index 0000000..bf01608 |
| --- /dev/null |
| +++ b/home/test/ceremony-errors.test.mjs |
| @@ -0,0 +1,88 @@ |
| +// Tests for the WebAuthn ceremony-error copy mapping, run in Node. |
| +// Same harness style as home/test/serverlist.test.mjs. |
| +// node home/test/ceremony-errors.test.mjs |
| +import assert from "node:assert/strict"; |
| +import { friendlyCeremonyError, formatCeremonyError } from "../web/static/ceremony-errors.js"; |
| + |
| +let failures = 0; |
| +async function test(name, fn) { |
| + try { |
| + await fn(); |
| + console.log("PASS " + name); |
| + } catch (e) { |
| + console.log("FAIL " + name + " — " + (e && e.message)); |
| + failures++; |
| + } |
| +} |
| + |
| +// A ceremony error as the browser throws it: name is a DOMException name, |
| +// phase is the tag backup-core.js attaches on the register path. |
| +const errWith = (name, phase) => Object.assign(new Error("boom"), { name, phase }); |
| + |
| +await test("cancelled sign-in reads as a cancel, not an exception", () => { |
| + const { message, detail } = friendlyCeremonyError(errWith("NotAllowedError"), "signin"); |
| + assert.equal(message, "Sign in was cancelled. If you didn't cancel, try again."); |
| + assert.equal(detail, "NotAllowedError"); |
| +}); |
| + |
| +await test("cancelled create names the create intent", () => { |
| + const { message } = friendlyCeremonyError(errWith("NotAllowedError"), "create"); |
| + assert.equal(message, "Creating your sign-in was cancelled. If you didn't cancel, try again."); |
| +}); |
| + |
| +await test("duplicate registration bridges back to sign-in", () => { |
| + const { message } = friendlyCeremonyError(errWith("InvalidStateError"), "create"); |
| + assert.equal(message, "That's already set up. Try signing in instead."); |
| +}); |
| + |
| +await test("InvalidStateError during sign-in falls through to the raw message", () => { |
| + const { message } = friendlyCeremonyError(errWith("InvalidStateError"), "signin"); |
| + assert.equal(message, "boom"); // no create-specific reading applies |
| +}); |
| + |
| +await test("no screen lock: ConstraintError and NotSupportedError both explain it", () => { |
| + for (const name of ["ConstraintError", "NotSupportedError"]) { |
| + const { message } = friendlyCeremonyError(errWith(name), "signin"); |
| + assert.equal(message, |
| + "This device needs a screen lock, PIN, fingerprint, or face unlock before it can sign in this way."); |
| + } |
| +}); |
| + |
| +await test("misconfigured RP reads as a site problem", () => { |
| + const { message } = friendlyCeremonyError(errWith("SecurityError"), "signin"); |
| + assert.equal(message, "Sign-in isn't configured for this website address."); |
| +}); |
| + |
| +await test("aborted ceremony asks for a retry", () => { |
| + const { message } = friendlyCeremonyError(errWith("AbortError"), "signin"); |
| + assert.equal(message, "Sign-in was interrupted. Try again."); |
| +}); |
| + |
| +await test("phase rides along in the detail for debugging", () => { |
| + const { detail } = friendlyCeremonyError(errWith("NotAllowedError", "create"), "create"); |
| + assert.equal(detail, "NotAllowedError [create]"); |
| +}); |
| + |
| +await test("unknown errors pass their own message through", () => { |
| + const { message, detail } = friendlyCeremonyError(new Error("HTTP 500"), "signin"); |
| + assert.equal(message, "HTTP 500"); |
| + assert.equal(detail, "Error"); |
| +}); |
| + |
| +await test("a nameless, messageless value still renders something", () => { |
| + const { message, detail } = friendlyCeremonyError({}, "signin"); |
| + assert.equal(message, "[object Object]"); |
| + assert.equal(detail, "Error"); |
| +}); |
| + |
| +await test("formatCeremonyError composes sentence + parenthesised detail", () => { |
| + const line = formatCeremonyError(errWith("NotAllowedError", "create"), "create"); |
| + assert.equal(line, |
| + "Creating your sign-in was cancelled. If you didn't cancel, try again. (NotAllowedError [create])"); |
| +}); |
| + |
| +if (failures) { |
| + console.error(failures + " test(s) failed"); |
| + process.exit(1); |
| +} |
| +console.log("ceremony-errors tests passed"); |
diff --git a/home/web/pages/enroll.html b/home/web/pages/enroll.html| index d2b00e2..2a8b527 100644 |
| --- a/home/web/pages/enroll.html |
| +++ b/home/web/pages/enroll.html |
| @@ -5,7 +5,7 @@ |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <meta name="theme-color" content="#f6f7fb" media="(prefers-color-scheme: light)"> |
| <meta name="theme-color" content="#15171c" media="(prefers-color-scheme: dark)"> |
| -<title>Save this chat securely</title> |
| +<title>Keep your access to this chat</title> |
| <link rel="stylesheet" href="/static/home.css"> |
| </head> |
| <body> |
| @@ -27,13 +27,19 @@ |
| <div class="host" id="entry-host"></div> |
| </div> |
| - <h1>Save this chat securely</h1> |
| - <p class="sub">Your key to this chat is sealed behind your passkey — your face |
| - or fingerprint opens it, nothing to remember. Not even this server can see |
| - inside.</p> |
| + <h1>Keep your access to this chat</h1> |
| + <p class="sub">Sign in with Eleven and your key to this chat stays safe — your |
| + face or fingerprint opens it, nothing to remember. Not even this server can |
| + see inside.</p> |
| + <!-- Both intents up front, clearly separated: signing in (the primary path — |
| + it adds this chat to the account you already have) and creating a |
| + sign-in for someone genuinely new. Nobody has to fail a ceremony to |
| + discover the path that was theirs. --> |
| <div class="actions" id="actions"> |
| - <button id="save">Save securely</button> |
| + <button id="save">Sign in and save</button> |
| + <div class="path-divider">New here?</div> |
| + <button id="create-new" class="secondary" type="button">I'm new — create my sign-in</button> |
| </div> |
| <!-- Inline status only — never a floating toast (product-wide rule). --> |
diff --git a/home/web/pages/index.html b/home/web/pages/index.html| index 5397981..6273165 100644 |
| --- a/home/web/pages/index.html |
| +++ b/home/web/pages/index.html |
| @@ -10,8 +10,8 @@ |
| <link rel="apple-touch-icon" href="/static/icon-512.png"> |
| <link rel="stylesheet" href="/static/home.css"> |
| <style> |
| - /* The landing is a single centered card: the Eleven mark, one button, and a |
| - reassuring line. Your spaces appear only once you've signed in. */ |
| + /* The landing is a single centered card: the Eleven mark, the two sign-in |
| + paths, and a reassuring line. Your spaces appear only once you've signed in. */ |
| /* Center the card + hint as one group. The shared body is a stretch grid |
| (fine for the single-card ceremony pages); with two children here it would |
| otherwise push them to opposite ends of the viewport. */ |
| @@ -27,18 +27,14 @@ |
| font-weight: 500; font-size: 26px; letter-spacing: -0.02em; |
| } |
| #signin { width: 100%; } |
| - /* The after-failure panel: a quiet inset, left-aligned so the longer copy |
| - reads as a message rather than more hero text. Its button sits on the |
| - panel tint, so it wears the card colour instead of .secondary's. */ |
| + /* The after-failure explainer: a quiet inset, left-aligned so the longer copy |
| + reads as a message rather than more hero text. Reinforcement only — both |
| + action paths already sit above it, up front. */ |
| #newhere { |
| margin: 14px 0 0; padding: 14px; border-radius: 14px; |
| background: var(--quiet); text-align: left; font-size: 14px; line-height: 1.45; |
| } |
| - #newhere p { margin: 0 0 12px; } |
| - #newhere button { |
| - width: 100%; background: var(--panel); color: var(--ink); |
| - border: 1px solid var(--border); |
| - } |
| + #newhere p { margin: 0; } |
| /* The reassurance sits OUTSIDE the card, under it. */ |
| .reassure { |
| max-width: 360px; text-align: center; margin: 16px auto 0; |
| @@ -434,23 +430,27 @@ |
| <div class="brand-word">Eleven</div> |
| </div> |
| - <!-- The signed-out affordances; hidden once you're in (see showVault). --> |
| + <!-- The signed-out affordances; hidden once you're in (see showVault). Both |
| + intents are visible up front — Sign in for returning members, a quieter |
| + create for someone genuinely new — so nobody has to fail a ceremony to |
| + find their path. --> |
| <div id="signed-out"> |
| <div class="actions"> |
| <button id="signin">Sign in</button> |
| + <div class="path-divider">New here?</div> |
| + <button id="create-passkey" class="secondary" type="button">Create your sign-in</button> |
| </div> |
| - <!-- Revealed only after a failed sign-in. Cancelled and no-passkey are |
| - deliberately indistinguishable (NotAllowedError either way), so this |
| - panel serves both readings: steer a returning user back to Sign in — |
| - one vault opens every space, a duplicate would shadow it — and offer |
| - create to someone genuinely new. --> |
| + <!-- Revealed after a failed sign-in, as reinforcement. Cancelled and |
| + no-sign-in are deliberately indistinguishable (NotAllowedError either |
| + way), so this explainer serves both readings: steer a returning user |
| + back to Sign in — one sign-in opens every space, a duplicate would |
| + shadow it — and point someone genuinely new at Create. --> |
| <div id="newhere" class="hidden screen"> |
| - <p><strong>New here?</strong> Your Eleven sign-in is one vault that opens |
| - every space you belong to — you only ever need one. If you already |
| - have one, tap Sign in again and choose it. Otherwise, create yours — |
| - next time you'll sign in straight away.</p> |
| - <button id="create-passkey" type="button">Create your sign-in</button> |
| + <p><strong>Which one is yours?</strong> Your Eleven sign-in is one key that |
| + opens every space you belong to — you only ever need one. If you've |
| + signed in before, tap Sign in and choose it from the list. Create a new |
| + sign-in only if you've never made one.</p> |
| </div> |
| </div> |
diff --git a/home/web/static/ceremony-errors.js b/home/web/static/ceremony-errors.js| new file mode 100644 |
| index 0000000..3ab6f21 |
| --- /dev/null |
| +++ b/home/web/static/ceremony-errors.js |
| @@ -0,0 +1,59 @@ |
| +// Friendly copy for WebAuthn ceremony failures — pure and DOM-free so it's |
| +// unit-testable in Node (home/test/ceremony-errors.test.mjs) and shared by the |
| +// home pages (home.js, enroll.js, restore.js). |
| +// |
| +// The browser's ceremony errors are famously opaque (NotAllowedError is the |
| +// catch-all for "cancelled", "timed out", "blocked", and "no credential"), so |
| +// members read a specific, human sentence instead of an exception name. The |
| +// technical detail (error name + the phase tag backup-core.js attaches) is |
| +// deliberately kept — parenthesised after the sentence — because it's the only |
| +// signal we have when debugging a member's report. |
| + |
| +// friendlyCeremonyError maps a ceremony failure to member-facing copy. |
| +// `ceremony` is the intent the user pressed: "signin" (credentials.get) or |
| +// "create" (credentials.create). Returns { message, detail }: message is the |
| +// friendly sentence, detail the technical crumb (e.g. "NotAllowedError [create]"). |
| +export function friendlyCeremonyError(err, ceremony) { |
| + const name = (err && err.name) || ""; |
| + const phase = err && err.phase ? " [" + err.phase + "]" : ""; |
| + const detail = (name || "Error") + phase; |
| + const creating = ceremony === "create"; |
| + switch (name) { |
| + case "NotAllowedError": |
| + // Cancelled, timed out, or the browser refused — indistinguishable by |
| + // design, so the copy covers both the deliberate and the accidental read. |
| + return { |
| + message: creating |
| + ? "Creating your sign-in was cancelled. If you didn't cancel, try again." |
| + : "Sign in was cancelled. If you didn't cancel, try again.", |
| + detail, |
| + }; |
| + case "InvalidStateError": |
| + // Duplicate registration: the device already holds a matching sign-in. |
| + if (creating) return { message: "That's already set up. Try signing in instead.", detail }; |
| + break; |
| + case "ConstraintError": |
| + case "NotSupportedError": |
| + // In practice: user verification is required but the device has nothing |
| + // to verify with — no screen lock set up. |
| + return { |
| + message: "This device needs a screen lock, PIN, fingerprint, or face unlock before it can sign in this way.", |
| + detail, |
| + }; |
| + case "SecurityError": |
| + // RP id / origin mismatch — a deployment problem, not the member's. |
| + return { message: "Sign-in isn't configured for this website address.", detail }; |
| + case "AbortError": |
| + return { message: "Sign-in was interrupted. Try again.", detail }; |
| + } |
| + // Anything else (network errors, our own thrown Errors) already carries a |
| + // human message — pass it through under the same shape. |
| + return { message: (err && err.message) || String(err), detail }; |
| +} |
| + |
| +// formatCeremonyError composes the one-line status string every caller renders: |
| +// the friendly sentence plus the kept-on-purpose technical detail. |
| +export function formatCeremonyError(err, ceremony) { |
| + const { message, detail } = friendlyCeremonyError(err, ceremony); |
| + return message + " (" + detail + ")"; |
| +} |
diff --git a/home/web/static/enroll.js b/home/web/static/enroll.js| index bedd2ff..c502b32 100644 |
| --- a/home/web/static/enroll.js |
| +++ b/home/web/static/enroll.js |
| @@ -18,6 +18,7 @@ import { |
| sealBlob, unpackBlob, openBlob, |
| } from "/static/crypto.js"; |
| import { beginRegister, beginLogin, runRegister, runLogin, fetchBlob, putBlob } from "/static/backup-core.js"; |
| +import { formatCeremonyError } from "/static/ceremony-errors.js"; |
| import { clearTombstone } from "/static/serverlist.js"; |
| const $ = (id) => document.getElementById(id); |
| @@ -74,7 +75,7 @@ const returnOrigin = payload ? new URL(payload.ret || payload.entry.url).origin |
| function goBack(withNonce) { |
| if (payload && payload.native) { |
| setStatus(withNonce |
| - ? "Backup complete. You can close this tab and return to Eleven." |
| + ? "All saved. You can close this tab and return to Eleven." |
| : "You can close this tab and return to Eleven.", withNonce ? "ok" : ""); |
| return; |
| } |
| @@ -114,20 +115,21 @@ function prefetch() { |
| beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; }); |
| } |
| -// saveSecurely is the one button. Sign in to an existing backup FIRST and add |
| -// this chat to it; only if no backup is found do we offer to create one. This |
| -// removes the up-front "create vs. sign in" choice — and, crucially, a returning |
| -// user always adds to their one backup instead of accidentally starting a second. |
| +// saveSecurely is the primary path: sign in to the account you already have and |
| +// add this chat to it. Sign-in stays first so a returning user always adds to |
| +// their one account instead of accidentally starting a second — but someone who |
| +// KNOWS they're new never has to fail a ceremony to find their path: the |
| +// up-front "I'm new" button (createAndAdd) runs the register path directly. |
| async function saveSecurely() { |
| disable(); |
| - setStatus("Checking for your saved backup…", "busy"); |
| + setStatus("Looking for your sign-in…", "busy"); |
| let login; |
| try { |
| login = await runLogin(loginBegin); |
| } catch (e) { |
| - // NotAllowedError here means no backup on an authenticator (or the sign-in |
| - // prompt was dismissed). Offer to create one — with a way back to sign-in so |
| - // a returning user who dismissed doesn't accidentally start a second backup. |
| + // NotAllowedError here means no sign-in on an authenticator (or the prompt |
| + // was dismissed). Offer to create one — with a way back to sign-in so a |
| + // returning user who dismissed doesn't accidentally start a second account. |
| if (e && e.name === "NotAllowedError") { offerCreate(); return; } |
| throw e; |
| } |
| @@ -147,20 +149,20 @@ async function createBlobFor({ token, credId, prf }) { |
| }; |
| const sealed = await sealBlob(vault.obj, dek, vault.keys); |
| const v = await putBlob(token, sealed, vault.version); |
| - if (v === null) throw new Error("a backup already exists for this passkey — sign in instead"); |
| + if (v === null) throw new Error("That's already set up — sign in instead."); |
| succeed(); |
| } |
| -// createAndAdd: register a brand-new passkey, then start a backup under it. |
| +// createAndAdd: register a brand-new sign-in, then save this chat under it. |
| async function createAndAdd() { |
| disable(); |
| - setStatus("Waiting for your passkey…", "busy"); |
| + setStatus("Creating your sign-in…", "busy"); |
| let reg; |
| try { |
| reg = await runRegister(regBegin); |
| } catch (e) { |
| if (e.message === "no-prf") { |
| - setStatus("This passkey manager can't protect a backup (no PRF support). You can still use add-device links to move between devices.", "error"); |
| + setStatus("This device can't protect your sign-in here (no PRF support). You can still use add-device links to move between devices.", "error"); |
| enable(); |
| return; |
| } |
| @@ -172,7 +174,7 @@ async function createAndAdd() { |
| // addWithLogin adds this chat to the backup behind an already-signed-in passkey. |
| async function addWithLogin({ token, credId, prf }) { |
| if (!prf) { |
| - setStatus("Signed in, but this browser didn't return a PRF result, so the backup can't be unlocked here.", "error"); |
| + setStatus("Signed in, but this browser couldn't unlock your saved spaces here (no PRF result).", "error"); |
| enable(); |
| return; |
| } |
| @@ -187,7 +189,7 @@ async function addWithLogin({ token, credId, prf }) { |
| const { keys } = unpackBlob(got.bytes); |
| const wrapped = keys[credId]; |
| if (!wrapped) { |
| - setStatus("This passkey isn't one of the backup's keys.", "error"); |
| + setStatus("That sign-in can't unlock these spaces.", "error"); |
| enable(); |
| return; |
| } |
| @@ -210,7 +212,7 @@ async function addWithLogin({ token, credId, prf }) { |
| // The replace prompt replaced the action buttons, so this is terminal: |
| // leave a clear message; the user goes back / closes the window. |
| $("actions").classList.add("hidden"); |
| - setStatus("Kept your existing backup for this chat — nothing changed.", "ok"); |
| + setStatus("Kept your existing access to this chat — nothing changed.", "ok"); |
| return; |
| } |
| } |
| @@ -242,22 +244,24 @@ function setBusy(busy) { |
| function disable() { setBusy(true); } |
| function enable() { setBusy(false); } |
| -// offerCreate: shown only after sign-in found no backup. Creating is the |
| -// secondary path here (it starts a NEW backup), with a prominent way back to |
| -// sign-in so a returning user who dismissed the prompt can't fragment. |
| +// offerCreate: the fallback after a sign-in that found nothing (the up-front |
| +// "I'm new" button skips straight to createAndAdd). Creating leads here — the |
| +// sign-in just came back empty — with a way back to sign-in so a returning |
| +// user who dismissed the prompt can't fragment into a second account. |
| function offerCreate() { |
| const actions = $("actions"); |
| actions.textContent = ""; |
| - setStatus("No saved backup found on this device."); |
| + setStatus("No sign-in was found on this device — or the prompt was cancelled."); |
| const create = document.createElement("button"); |
| - create.textContent = "Create a new secure backup"; |
| - create.addEventListener("click", () => createAndAdd().catch(onError)); |
| + create.id = "create-fallback"; |
| + create.textContent = "Create my sign-in"; |
| + create.addEventListener("click", () => createAndAdd().catch((e) => onError(e, "create"))); |
| const retry = document.createElement("button"); |
| retry.className = "secondary"; |
| - retry.textContent = "I already have a backup — sign in"; |
| - retry.addEventListener("click", () => saveSecurely().catch(onError)); |
| + retry.textContent = "I already have a sign-in — try again"; |
| + retry.addEventListener("click", () => saveSecurely().catch((e) => onError(e, "signin"))); |
| const back = document.createElement("button"); |
| back.className = "secondary"; |
| @@ -283,7 +287,7 @@ function confirmReplace(existing) { |
| `Replace it with "${payload.entry.name || host}"? Your current saved access for this chat will be overwritten.`; |
| const replace = document.createElement("button"); |
| - replace.textContent = "Replace saved backup"; |
| + replace.textContent = "Replace my saved access"; |
| const cancel = document.createElement("button"); |
| cancel.className = "secondary"; |
| @@ -323,19 +327,12 @@ function succeed() { |
| setTimeout(() => goBack(true), 700); // returns with the one-time nonce so the instance trusts it |
| } |
| -const onError = (err) => { |
| +// onError renders a friendly, specific sentence for whichever ceremony the |
| +// user was in (ceremony-errors.js), keeping the name+phase crumb parenthesised |
| +// — that detail is deliberate; a bland "dismissed" hid the real signal. |
| +const onError = (err, ceremony) => { |
| try { console.error("[enrol] error", err && err.phase, err); } catch {} |
| - const phase = err && err.phase ? ` [${err.phase}]` : ""; |
| - if (err && err.name === "NotAllowedError") { |
| - // NotAllowedError is WebAuthn's catch-all — a real user cancel, or the |
| - // browser aborting the ceremony. Surface the phase so we can tell which; a |
| - // bland "dismissed" hid the real signal. |
| - setStatus(`Passkey step blocked or cancelled${phase} (NotAllowedError). If you didn't cancel, try again.`, "error"); |
| - enable(); |
| - prefetch(); // refresh options so the retry has a live challenge |
| - return; |
| - } |
| - setStatus(`${(err && err.name) || "Error"}${phase}: ${(err && err.message) || String(err)}`, "error"); |
| + setStatus(formatCeremonyError(err, ceremony), "error"); |
| enable(); |
| prefetch(); // a used/failed ceremony's options are spent — refresh for a retry |
| }; |
| @@ -343,7 +340,7 @@ const onError = (err) => { |
| // ---- boot --------------------------------------------------------------------- |
| if (!payload) { |
| - setStatus("This page was opened without a valid request. Go back to your chat and use the “Back up” button.", "error"); |
| + setStatus("This page was opened without a valid request. Go back to your chat and try again.", "error"); |
| $("actions").classList.add("hidden"); |
| $("entry").classList.add("hidden"); // no entry to show — a placeholder tile would mislead |
| } else { |
| @@ -360,13 +357,15 @@ if (!payload) { |
| } |
| } |
| if (payload && !window.PublicKeyCredential) { |
| - setStatus("This browser doesn't support passkeys.", "error"); |
| + setStatus("This browser can't sign you in — try another browser.", "error"); |
| $("actions").classList.add("hidden"); |
| } else if (payload) { |
| - // Pre-fetch ceremony options now, while the user reads the card — so the click |
| - // reaches the authenticator inside the gesture (Safari activation, see prefetch). |
| + // Pre-fetch ceremony options now, while the user reads the card — so either |
| + // button's click reaches the authenticator inside the gesture (Safari |
| + // activation, see prefetch): regBegin covers "I'm new", loginBegin the sign-in. |
| prefetch(); |
| - $("save").addEventListener("click", () => saveSecurely().catch(onError)); |
| + $("save").addEventListener("click", () => saveSecurely().catch((e) => onError(e, "signin"))); |
| + $("create-new").addEventListener("click", () => createAndAdd().catch((e) => onError(e, "create"))); |
| // A way back without saving (same-tab navigation replaced the chat). |
| const back = document.createElement("button"); |
diff --git a/home/web/static/home.css b/home/web/static/home.css| index fdd8508..ebe3f45 100644 |
| --- a/home/web/static/home.css |
| +++ b/home/web/static/home.css |
| @@ -85,6 +85,18 @@ body { |
| .actions { display: flex; flex-direction: column; gap: 10px; } |
| .actions p { margin: 0 0 4px; font-size: 15px; } |
| +/* The two-intent divider: "Sign in" above it, the create path for people who |
| + are new below — both visible up front, so nobody has to fail a ceremony to |
| + discover which path was theirs. Muted rules either side of a short label; |
| + --muted is the status-text colour, so contrast holds in both themes. */ |
| +.path-divider { |
| + display: flex; align-items: center; gap: 10px; |
| + margin: 2px 0; color: var(--muted); font-size: 13px; |
| +} |
| +.path-divider::before, .path-divider::after { |
| + content: ""; flex: 1; height: 1px; background: var(--border); |
| +} |
| + |
| button { |
| font: inherit; |
| font-weight: 600; |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 2027a0f..4c6e656 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -1,16 +1,18 @@ |
| -// The home landing page: one "Sign in" button that unlocks your list of |
| -// spaces, plus a Sign out that forgets this browser's session. All crypto is |
| -// client-side (crypto.js); the WebAuthn ceremonies + blob I/O live in |
| -// backup-core.js. Status renders inline — never a toast. |
| +// The home landing page: "Sign in" unlocks your list of spaces, plus a Sign |
| +// out that forgets this browser's session. All crypto is client-side |
| +// (crypto.js); the WebAuthn ceremonies + blob I/O live in backup-core.js. |
| +// Status renders inline — never a toast. |
| // |
| -// Sign-in comes first, always. Only after a FAILED sign-in does the page offer |
| -// to create a passkey (the #newhere panel): a cancelled prompt and a missing |
| -// credential are deliberately indistinguishable (NotAllowedError either way), |
| -// so the panel steers a returning user back to Sign in — one vault opens every |
| -// space, and a duplicate empty vault would shadow the real one — while giving |
| -// someone genuinely new the create path. "Sign in" is the friendly name for |
| -// unlocking the encrypted key vault; the server only ever holds opaque |
| -// ciphertext, never a usable key. |
| +// Both intents are visible up front: Sign in (primary) and, quieter beneath a |
| +// divider, Create your sign-in for someone genuinely new — nobody has to fail |
| +// a ceremony to find their path. Sign-in still leads: a cancelled prompt and a |
| +// missing credential are deliberately indistinguishable (NotAllowedError |
| +// either way), so a failed sign-in reveals the #newhere explainer steering a |
| +// returning user back to Sign in — one sign-in opens every space, and a |
| +// duplicate empty account would shadow the real one — while pointing someone |
| +// genuinely new at Create. "Sign in" is the friendly name for unlocking the |
| +// encrypted key vault; the server only ever holds opaque ciphertext, never a |
| +// usable key. |
| import { |
| kekFromPRF, generateDEK, exportDEK, importDEK, wrapDEK, unwrapDEK, |
| @@ -19,6 +21,7 @@ import { |
| import { |
| apiJSON, beginLogin, beginRegister, runLogin, runRegister, fetchBlob, putBlob, |
| } from "/static/backup-core.js"; |
| +import { friendlyCeremonyError, formatCeremonyError } from "/static/ceremony-errors.js"; |
| import { |
| partitionServers, partitionArchived, setArchived, |
| toggleStar, moveInGroup, orderServers, filterServers, soleAdminHold, |
| @@ -144,29 +147,36 @@ async function saveVault() { |
| // ---- sign in ------------------------------------------------------------------ |
| +// Both signed-out buttons disable together while either ceremony runs — a |
| +// second WebAuthn request would only cancel the sheet that's already up. |
| +function setSignedOutBusy(busy) { |
| + $("signin").disabled = busy; |
| + $("create-passkey").disabled = busy; |
| +} |
| + |
| async function signIn() { |
| - $("signin").disabled = true; |
| - setStatus("Waiting for your passkey…", "busy"); |
| + setSignedOutBusy(true); |
| + setStatus("Confirming it’s you…", "busy"); |
| const { token, credId, prf } = await runLogin(loginBegin); |
| state.token = token; |
| state.credId = credId; |
| const got = await fetchBlob(token); |
| if (!got) { |
| - setStatus("Signed in, but no spaces are saved to this passkey yet.", "error"); |
| - $("signin").disabled = false; |
| + setStatus("Signed in, but no spaces are saved to this sign-in yet.", "error"); |
| + setSignedOutBusy(false); |
| return; |
| } |
| if (!prf) { |
| setStatus("Signed in, but this browser didn't return a PRF result, so your spaces can't be unlocked here.", "error"); |
| - $("signin").disabled = false; |
| + setSignedOutBusy(false); |
| return; |
| } |
| const kek = await kekFromPRF(prf); |
| const { keys } = unpackBlob(got.bytes); |
| const wrapped = keys[credId]; |
| if (!wrapped) { |
| - setStatus("This passkey isn't one of this account's keys.", "error"); |
| - $("signin").disabled = false; |
| + setStatus("That sign-in can't unlock these spaces.", "error"); |
| + setSignedOutBusy(false); |
| return; |
| } |
| dekKey = await unwrapDEK(wrapped, kek); |
| @@ -177,15 +187,15 @@ async function signIn() { |
| showVault(); |
| } |
| -// ---- create a sign-in (the #newhere panel's action) ---------------------------- |
| +// ---- create a sign-in (the up-front "Create your sign-in" action) -------------- |
| // createPasskey registers a brand-new passkey and starts an empty vault under |
| -// it, then lands in the (empty) spaces list like any sign-in. Reached only via |
| -// the after-failure panel — sign-in is always tried first (see the header note). |
| +// it, then lands in the (empty) spaces list like any sign-in. Reached from the |
| +// up-front create button — quieter than Sign in, but never hidden behind a |
| +// failed ceremony (see the header note). |
| async function createPasskey() { |
| - const btn = $("create-passkey"); |
| - btn.disabled = true; |
| - setStatus("Waiting for your passkey…", "busy"); |
| + setSignedOutBusy(true); |
| + setStatus("Creating your sign-in…", "busy"); |
| let reg; |
| try { |
| reg = await runRegister(registerBegin); |
| @@ -193,8 +203,8 @@ async function createPasskey() { |
| registerBegin = null; |
| prefetchRegister(); // options were spent — refresh for a retry |
| if (e.message === "no-prf") { |
| - setStatus("This passkey manager can't protect your sign-in here (no PRF support). You can still use add-device links to move between devices.", "error"); |
| - btn.disabled = false; |
| + setStatus("This device can't protect your sign-in here (no PRF support). You can still use add-device links to move between devices.", "error"); |
| + setSignedOutBusy(false); |
| return; |
| } |
| throw e; |
| @@ -215,7 +225,7 @@ async function createPasskey() { |
| keys: { [reg.credId]: await wrapDEK(dekKey, kek) }, |
| version: "0", |
| }; |
| - btn.disabled = false; |
| + setSignedOutBusy(false); |
| setStatus("Your sign-in is ready — next time it's one tap.", "ok"); |
| showVault(); |
| } |
| @@ -241,9 +251,9 @@ function signOut() { |
| clearUndo(); |
| $("spaces").textContent = ""; |
| $("signed-out").classList.remove("hidden"); |
| - $("newhere").classList.add("hidden"); // fresh signed-out state, no stale panel |
| + $("newhere").classList.add("hidden"); // fresh signed-out state, no stale explainer |
| $("reassure").classList.remove("hidden"); |
| - $("signin").disabled = false; |
| + setSignedOutBusy(false); |
| setStatus("Signed out.", ""); |
| prefetchLogin(); // ready a fresh ceremony for the next sign-in |
| } |
| @@ -1207,13 +1217,13 @@ async function destroyForEveryone() { |
| const host = $("stern-host").textContent; |
| const btn = $("destroy-forever"); |
| btn.disabled = true; |
| - archiveStatus("Confirm with your passkey…", "busy"); |
| + archiveStatus("Confirm it’s you…", "busy"); |
| let proof; |
| try { |
| ({ proof } = await runLogin(loginBegin)); |
| } catch (e) { |
| prefetchLogin(); |
| - archiveStatus(e.name === "NotAllowedError" ? "Passkey prompt was dismissed." : (e.message || String(e)), "error"); |
| + archiveStatus(friendlyCeremonyError(e, "signin").message, "error"); |
| btn.disabled = false; |
| return; |
| } |
| @@ -1242,32 +1252,34 @@ async function destroyForEveryone() { |
| // ---- wiring ------------------------------------------------------------------- |
| const onSignInError = (err) => { |
| - // A cancelled prompt and a missing credential both surface as NotAllowedError, |
| - // deliberately. The #newhere panel handles both readings — it steers returning |
| - // users back to Sign in and offers create to the genuinely new — so it carries |
| - // the whole message and the status line stays quiet. |
| + // A friendly, specific line for what went wrong (ceremony-errors.js), with |
| + // the technical crumb parenthesised. A cancelled prompt and a missing |
| + // credential both surface as NotAllowedError, deliberately — so that case |
| + // also reveals the #newhere explainer, which serves both readings: it steers |
| + // returning users back to Sign in and the genuinely new at Create (already |
| + // visible above it). |
| + setStatus(formatCeremonyError(err, "signin"), "error"); |
| if (err && err.name === "NotAllowedError") { |
| - setStatus(""); |
| $("newhere").classList.remove("hidden"); |
| prefetchRegister(); // ready the create ceremony in case they're new |
| - } else { |
| - setStatus((err && err.message) || String(err), "error"); |
| } |
| - $("signin").disabled = false; |
| + setSignedOutBusy(false); |
| prefetchLogin(); // options were spent — refresh for a retry |
| }; |
| const onCreatePasskeyError = (err) => { |
| - if (err && err.name === "NotAllowedError") setStatus("Passkey prompt was dismissed.", ""); |
| - else setStatus((err && err.message) || String(err), "error"); |
| - $("create-passkey").disabled = false; |
| + setStatus(formatCeremonyError(err, "create"), "error"); |
| + setSignedOutBusy(false); |
| }; |
| if (!window.PublicKeyCredential) { |
| - setStatus("This browser doesn't support passkeys.", "error"); |
| - $("signin").disabled = true; |
| + setStatus("This browser can't sign you in — try another browser.", "error"); |
| + setSignedOutBusy(true); |
| } else { |
| + // Both up-front buttons get pre-fetched ceremony options, so either click |
| + // reaches navigator.credentials.* inside the gesture (Safari activation). |
| prefetchLogin(); |
| + prefetchRegister(); |
| $("signin").addEventListener("click", () => signIn().catch(onSignInError)); |
| $("create-passkey").addEventListener("click", () => createPasskey().catch(onCreatePasskeyError)); |
| } |
diff --git a/home/web/static/restore.js b/home/web/static/restore.js| index 546bbc4..1a1e745 100644 |
| --- a/home/web/static/restore.js |
| +++ b/home/web/static/restore.js |
| @@ -15,6 +15,7 @@ import { |
| b64urlToBytes, bytesToB64url, kekFromPRF, unwrapDEK, exportDEK, unpackBlob, openBlob, wrapToPublicKey, |
| } from "/static/crypto.js"; |
| import { beginLogin, runLogin, fetchBlob } from "/static/backup-core.js"; |
| +import { formatCeremonyError } from "/static/ceremony-errors.js"; |
| import { validRestorePayload, cliReturnURL } from "/static/restore-payload.js"; |
| const $ = (id) => document.getElementById(id); |
| @@ -179,11 +180,9 @@ async function restore() { |
| } |
| const onError = (err) => { |
| - if (err && err.name === "NotAllowedError") { |
| - setStatus("Sign-in was cancelled or blocked. If you didn't cancel, try again.", "error"); |
| - } else { |
| - setStatus((err && err.message) || String(err), "error"); |
| - } |
| + // Friendly, specific failure copy (ceremony-errors.js) with the technical |
| + // crumb parenthesised — kept on purpose for debugging member reports. |
| + setStatus(formatCeremonyError(err, "signin"), "error"); |
| // Reveal the retry button — the auto-run keeps it hidden until it's needed |
| // (a cancelled sheet, or a browser that refuses a no-gesture ceremony). |
| $("actions").classList.remove("hidden"); |
diff --git a/package.json b/package.json| index 6a0bb41..88b1762 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs" |
| } |
| } |
diff --git a/web/static/backup.js b/web/static/backup.js| index 4035e5c..f641506 100644 |
| --- a/web/static/backup.js |
| +++ b/web/static/backup.js |
| @@ -174,20 +174,20 @@ export function initBackupBanner(session, opts = {}) { |
| function renderBanner(el, session) { |
| el.textContent = ""; |
| - prefetchServerIcon(); // ready the branding photo before the user taps "Save securely" |
| + prefetchServerIcon(); // ready the branding photo before the user taps "Sign in" |
| const title = document.createElement("div"); |
| title.className = "backup-banner-title"; |
| - title.textContent = "Save your chat securely"; |
| + title.textContent = "Keep your access safe"; |
| const body = document.createElement("div"); |
| body.className = "backup-banner-body"; |
| body.textContent = |
| - "Saving lets you get back to your chats later and on your other devices. It's protected by a passkey — nothing to remember."; |
| + "Sign in with Eleven and you can get back to your chats anytime, on any device — one tap, nothing to remember."; |
| const cta = document.createElement("button"); |
| cta.className = "backup-banner-cta"; |
| - cta.textContent = "Save securely"; |
| + cta.textContent = "Sign in"; |
| cta.addEventListener("click", () => launchEnrol(session)); |
| // Dismissal. On the deferral schedule's early rungs (24h / 72h), "later" just |