eleven / messenger Public

home: offer create-a-sign-in after a failed sign-in (#353)

Sign-in stays the only button on the landing. When the passkey ceremony fails
(NotAllowedError — a cancelled prompt and a missing credential are deliberately
indistinguishable), a quiet inset panel appears below it:

  "New here? 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."  [Create your sign-in]

The panel serves both readings of the failure: it steers a returning user back
to Sign in (a duplicate empty vault would shadow the real one) and gives a
genuinely new person the create path. Create registers a passkey, starts an
empty vault, and lands in the spaces list like any sign-in. Register options
are prefetched when the panel appears (Safari user-activation), and the panel
resets on sign-out.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell cd94d0292592dd180dce6131da5b30d46d3bb594 parent 4327a14
2 files changed, +94 −11
  • home/web/pages/index.html +25 −0
  • home/web/static/home.js +69 −11
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index dd234b7..c291dfe 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -27,6 +27,18 @@
font-weight: 800; 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. */
+ #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);
+ }
/* The reassurance sits OUTSIDE the card, under it. */
.reassure {
max-width: 360px; text-align: center; margin: 16px auto 0;
@@ -144,6 +156,19 @@
<div class="actions">
<button id="signin">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. -->
+ <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>
+ </div>
</div>
<!-- Inline status only — never a floating toast (product-wide rule). -->
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 78f8084..f72f089 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -1,20 +1,23 @@
-// The home landing page: one "Sign in with Eleven" 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
+// 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.
//
-// Home is a sign-in surface only: a new member gets their passkey through the
-// join / save-a-space flow (/enroll), not here — so there's no "create an
-// account" path to clutter the page. "Sign in" is the friendly name for
+// 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.
import {
- kekFromPRF, exportDEK, importDEK, unwrapDEK,
+ kekFromPRF, generateDEK, exportDEK, importDEK, wrapDEK, unwrapDEK,
sealBlob, unpackBlob, openBlob,
} from "/static/crypto.js";
import {
- apiJSON, beginLogin, runLogin, fetchBlob, putBlob,
+ apiJSON, beginLogin, beginRegister, runLogin, runRegister, fetchBlob, putBlob,
} from "/static/backup-core.js";
import {
partitionServers, toggleStar, moveInGroup, orderServers, filterServers,
@@ -67,6 +70,11 @@ let loginBegin = null;
function prefetchLogin() {
beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; });
}
+let registerBegin = null;
+function prefetchRegister() {
+ if (registerBegin) return;
+ beginRegister().then((v) => { registerBegin = v; }).catch(() => { registerBegin = null; });
+}
// ---- blob save ----------------------------------------------------------------
@@ -126,6 +134,45 @@ async function signIn() {
showVault();
}
+// ---- create a sign-in (the #newhere panel's 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).
+async function createPasskey() {
+ const btn = $("create-passkey");
+ btn.disabled = true;
+ setStatus("Waiting for your passkey…", "busy");
+ let reg;
+ try {
+ reg = await runRegister(registerBegin);
+ } catch (e) {
+ 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;
+ return;
+ }
+ throw e;
+ }
+ state.token = reg.token;
+ state.credId = reg.credId;
+
+ const kek = await kekFromPRF(reg.prf);
+ dekKey = await generateDEK();
+ state.dek = await exportDEK(dekKey);
+ vault = {
+ obj: { v: 1, servers: [] },
+ keys: { [reg.credId]: await wrapDEK(dekKey, kek) },
+ version: "0",
+ };
+ await saveVault();
+ btn.disabled = false;
+ setStatus("Your sign-in is ready — next time it's one tap.", "ok");
+ showVault();
+}
+
// ---- sign out -----------------------------------------------------------------
// signOut forgets this browser's session — the link token and DEK held only in
@@ -142,6 +189,7 @@ function signOut() {
$("create").classList.add("hidden");
$("spaces").textContent = "";
$("signed-out").classList.remove("hidden");
+ $("newhere").classList.add("hidden"); // fresh signed-out state, no stale panel
$("reassure").classList.remove("hidden");
$("signin").disabled = false;
setStatus("Signed out.", "");
@@ -504,10 +552,13 @@ function closeCreate() {
const onSignInError = (err) => {
// A cancelled prompt and a missing credential both surface as NotAllowedError,
- // so keep the message neutral. New members get their passkey by joining or
- // saving a space from its chat, not here — no create button to offer.
+ // 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.
if (err && err.name === "NotAllowedError") {
- setStatus("Sign-in was cancelled, or there's no Eleven passkey on this device yet.", "");
+ setStatus("");
+ $("newhere").classList.remove("hidden");
+ prefetchRegister(); // ready the create ceremony in case they're new
} else {
setStatus((err && err.message) || String(err), "error");
}
@@ -515,12 +566,19 @@ const onSignInError = (err) => {
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;
+};
+
if (!window.PublicKeyCredential) {
setStatus("This browser doesn't support passkeys.", "error");
$("signin").disabled = true;
} else {
prefetchLogin();
$("signin").addEventListener("click", () => signIn().catch(onSignInError));
+ $("create-passkey").addEventListener("click", () => createPasskey().catch(onCreatePasskeyError));
}
$("signout").addEventListener("click", signOut);
$("open-create").addEventListener("click", openCreate);