eleven / messenger Public

home: add Sign out, drop the "First time?" create fallback (#323)

Two tweaks to the landing:

- Sign out: a link in the vault header that forgets this browser's session
  (the link token + DEK held only in sessionStorage) and returns to the
  signed-out view. It doesn't revoke the passkey or touch the backup — signing
  back in is one tap. The signed-out affordances now hide once you're in, so
  the two states are cleanly exclusive.
- One button, not two. Home is a sign-in surface: a new member gets their
  passkey through the join / save-a-space flow (/enroll), not here — so the
  "First time? Create your Eleven passkey" fallback was clutter. Removed it and
  its ceremony (register options, createPasskey), leaving a single "Sign in
  with Eleven". (We can't reliably tell "you cancelled" from "no passkey here"
  — both throw NotAllowedError — so auto-creating on failure would risk a
  returning user minting a duplicate empty account; sign-in-only avoids that.)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ac7ec8a6c8d6f637df9c8a9ee066be1bd04db28c parent 4f3ee79
2 files changed, +49 −69
  • home/web/pages/index.html +14 −13
  • home/web/static/home.js +35 −56
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 4aa877f..c978127 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -22,14 +22,14 @@
}
#signin { width: 100%; }
.reassure { color: var(--muted); font-size: 13px; line-height: 1.45; margin: 16px 0 0; }
- /* "New here?" — revealed only if signing in finds no passkey on this device. */
- .newhere { margin: 14px 0 0; font-size: 14px; }
.linkish {
background: none; border: none; padding: 0; font: inherit; font-weight: 600;
color: var(--accent); cursor: pointer; text-decoration: underline;
}
/* Your spaces, revealed after sign-in. */
#vault { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); text-align: left; }
+ .vault-head { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem; }
+ .vault-head #signout { font-size: .85rem; font-weight: 600; }
#vault h2 { font-size: 15px; margin: 0 0 4px; font-family: var(--font-display); }
ul.servers { list-style: none; margin: 0; padding: 0; }
ul.servers li {
@@ -56,23 +56,24 @@
<div class="brand-word">Eleven</div>
</div>
- <div class="actions">
- <button id="signin">Sign in with Eleven</button>
- </div>
-
- <p class="reassure">No signup required, we don't store any personal details.
- We can't spam what we don't have.</p>
+ <!-- The signed-out affordances; hidden once you're in (see showVault). -->
+ <div id="signed-out">
+ <div class="actions">
+ <button id="signin">Sign in with Eleven</button>
+ </div>
- <!-- Only shown if sign-in finds no passkey here (Safari can't distinguish a
- cancel from a missing credential, so we offer create on any failure). -->
- <p class="newhere hidden" id="newhere">First time?
- <button class="linkish" id="create">Create your Eleven passkey</button></p>
+ <p class="reassure">No signup required, we don't store any personal details.
+ We can't spam what we don't have.</p>
+ </div>
<!-- Inline status only — never a floating toast (product-wide rule). -->
<p class="status" id="status"></p>
<div id="vault" class="hidden">
- <h2>Your Eleven spaces</h2>
+ <div class="vault-head">
+ <h2>Your Eleven spaces</h2>
+ <button class="linkish" id="signout">Sign out</button>
+ </div>
<ul class="servers" id="server-list"></ul>
<!-- Create a new space — only where this home server offers provisioning
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 3347837..26a92b1 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -1,17 +1,20 @@
// The home landing page: one "Sign in with Eleven" button that unlocks your
-// list of spaces. A brand-new device (no passkey yet) can create one from the
-// fallback link. All crypto is client-side (crypto.js); the WebAuthn ceremonies
-// + blob I/O live in backup-core.js. Status renders inline — never a toast.
+// 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" is the friendly name for unlocking the encrypted key vault: the
-// server only ever holds opaque ciphertext, never a usable key.
+// 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
+// unlocking the encrypted key vault; the server only ever holds opaque
+// ciphertext, never a usable key.
import {
- kekFromPRF, generateDEK, exportDEK, importDEK, wrapDEK, unwrapDEK,
+ kekFromPRF, exportDEK, importDEK, unwrapDEK,
sealBlob, unpackBlob, openBlob,
} from "/static/crypto.js";
import {
- apiJSON, beginLogin, beginRegister, runRegister, runLogin, fetchBlob, putBlob,
+ apiJSON, beginLogin, runLogin, fetchBlob, putBlob,
} from "/static/backup-core.js";
// ---- session state ----------------------------------------------------------
@@ -58,14 +61,9 @@ let spacesEnabled = false;
// navigator.credentials.* synchronously — Safari drops the user activation
// across an intervening network await and rejects with NotAllowedError.
let loginBegin = null;
-let registerBegin = null;
function prefetchLogin() {
beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; });
}
-function prefetchRegister() {
- if (registerBegin) return;
- beginRegister().then((v) => { registerBegin = v; }).catch(() => { registerBegin = null; });
-}
// ---- blob save ----------------------------------------------------------------
@@ -125,43 +123,32 @@ async function signIn() {
showVault();
}
-// ---- create (fallback for a brand-new device) --------------------------------
-
-async function createPasskey() {
- $("create").disabled = true;
- setStatus("Waiting for your passkey…", "busy");
- let reg;
- try {
- reg = await runRegister(registerBegin);
- } catch (e) {
- registerBegin = null; prefetchRegister(); // spent — refresh for a retry
- if (e.message === "no-prf") {
- setStatus("This passkey manager can't protect your keys here (no PRF support). You can still use add-device links to move between devices.", "error");
- $("create").disabled = false;
- return;
- }
- throw e;
- }
- state.token = reg.token;
- state.credId = reg.credId;
+// ---- sign out -----------------------------------------------------------------
- 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();
- setStatus("Your Eleven passkey is ready. Spaces you save will appear here.", "ok");
- showVault();
+// signOut forgets this browser's session — the link token and DEK held only in
+// sessionStorage — and returns to the signed-out landing. It doesn't revoke the
+// passkey or touch the backup: signing back in is one tap. (Revoking a device
+// is a separate, ceremony-gated action.)
+function signOut() {
+ sessionStorage.removeItem("home_token");
+ sessionStorage.removeItem("home_dek");
+ sessionStorage.removeItem("home_cred");
+ vault = null;
+ dekKey = null;
+ $("vault").classList.add("hidden");
+ $("space-section").classList.add("hidden");
+ $("new-space-result").classList.add("hidden");
+ $("server-list").textContent = "";
+ $("signed-out").classList.remove("hidden");
+ $("signin").disabled = false;
+ setStatus("Signed out.", "");
+ prefetchLogin(); // ready a fresh ceremony for the next sign-in
}
// ---- spaces list --------------------------------------------------------------
function showVault() {
- $("newhere").classList.add("hidden");
+ $("signed-out").classList.add("hidden");
$("vault").classList.remove("hidden");
if (spacesEnabled) $("space-section").classList.remove("hidden");
const list = $("server-list");
@@ -278,34 +265,26 @@ async function addServer(e) {
// ---- wiring -------------------------------------------------------------------
const onSignInError = (err) => {
- // Safari surfaces both a cancelled prompt and a missing credential as
- // NotAllowedError, so we can't tell "you cancelled" from "nothing to sign in
- // with". Offer the create fallback on either and keep the message honest.
+ // 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.
if (err && err.name === "NotAllowedError") {
- setStatus("No passkey was used. If this device is new, create your Eleven passkey below.", "");
+ setStatus("Sign-in was cancelled, or there's no Eleven passkey on this device yet.", "");
} else {
setStatus((err && err.message) || String(err), "error");
}
- $("newhere").classList.remove("hidden");
- prefetchRegister();
$("signin").disabled = false;
prefetchLogin(); // options were spent — refresh for a retry
};
-const onCreateError = (err) => {
- if (err && err.name === "NotAllowedError") setStatus("Passkey prompt was dismissed.", "");
- else setStatus((err && err.message) || String(err), "error");
- $("create").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").addEventListener("click", () => createPasskey().catch(onCreateError));
}
+$("signout").addEventListener("click", signOut);
$("new-space").addEventListener("click", () => createSpace().catch((err) => {
if (err && err.name === "NotAllowedError") spaceStatus("Passkey prompt was dismissed.", "");
else spaceStatus((err && err.message) || String(err), "error");