home: reframe the home page as "Sign in with Eleven" (#308)
The home server stores an encrypted backup of a member's per-instance keys, but from the member's side that passkey *is* their Eleven identity: one face/fingerprint check unlocks the list of spaces they belong to. Reword the manage page (/) to match — "Sign in with Eleven" instead of "Create a backup", "Your Eleven spaces" instead of a server list, and make each space a link so you can jump straight back into any of them. The E2EE rule is unchanged: the server still only holds opaque ciphertext, "sign in" is just the friendly name for unlocking the sealed key vault.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+35
−29
home/web/pages/index.html+23 −19home/web/static/home.js+12 −10
diff --git a/home/web/pages/index.html b/home/web/pages/index.html| index 9b3925a..c45192c 100644 |
| --- a/home/web/pages/index.html |
| +++ b/home/web/pages/index.html |
| @@ -5,16 +5,16 @@ |
| <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>Eleven backup</title> |
| +<title>Sign in with Eleven</title> |
| <link rel="stylesheet" href="/static/home.css"> |
| <style> |
| - /* page-specific: the server + device lists and the add form */ |
| + /* page-specific: the spaces + device lists and the add form */ |
| ul.servers { list-style: none; margin: .75rem 0 0; padding: 0; } |
| ul.servers li { |
| display: flex; align-items: baseline; gap: .5rem; padding: .5rem 0; |
| border-top: 1px solid var(--border); |
| } |
| - ul.servers li .name { font-weight: 600; } |
| + ul.servers li a.name { font-weight: 600; color: inherit; } |
| ul.servers li .url { color: var(--muted); font-size: .85rem; overflow-wrap: anywhere; } |
| ul.devices { list-style: none; margin: .75rem 0 0; padding: 0; } |
| ul.devices li { |
| @@ -27,28 +27,24 @@ |
| ul.devices li button { padding: 6px 12px; font-size: 13px; } |
| .addform { display: flex; gap: .5rem; margin-top: .75rem; flex-wrap: wrap; } |
| .addform input { flex: 1; min-width: 8rem; } |
| + #vault h3 { font-size: 14px; margin: 16px 0 0; font-family: var(--font-display); } |
| </style> |
| </head> |
| <body class="manage"> |
| <main> |
| - <h1>Eleven backup</h1> |
| - <p class="sub">Your keys to your chats, sealed behind your passkeys. The |
| - backup is encrypted on your device — not even this server can see inside.</p> |
| - |
| - <section id="create-section"> |
| - <h2>Create a backup passkey</h2> |
| - <p>Saves a passkey on this device and starts an empty, encrypted backup. |
| - Use it later to get back into your chats on a new device.</p> |
| - <button id="create">Create a backup passkey</button> |
| - <p class="status" id="create-status"></p> |
| - </section> |
| + <h1>Sign in with Eleven</h1> |
| + <p class="sub">One passkey — your face or fingerprint — opens all your Eleven |
| + spaces. Your keys are sealed on your device before they're stored; not even |
| + this server can see inside.</p> |
| <section id="restore-section"> |
| - <h2>Restore</h2> |
| - <p>Unlock your backup with a passkey and see the chats saved in it.</p> |
| - <button id="restore">Unlock with passkey</button> |
| + <h2>Sign in</h2> |
| + <p>Unlock with your passkey to see your Eleven spaces and jump back into |
| + any of them.</p> |
| + <button id="restore">Sign in with passkey</button> |
| <p class="status" id="restore-status"></p> |
| <div id="vault" class="hidden"> |
| + <h3>Your Eleven spaces</h3> |
| <ul class="servers" id="server-list"></ul> |
| <form class="addform" id="add-form"> |
| <input id="add-name" placeholder="Name" autocomplete="off"> |
| @@ -59,10 +55,18 @@ |
| </div> |
| </section> |
| + <section id="create-section"> |
| + <h2>New here?</h2> |
| + <p>Create your Eleven passkey on this device. Spaces you save from their |
| + chat pages appear here, ready to sign back into anywhere.</p> |
| + <button id="create">Create your Eleven passkey</button> |
| + <p class="status" id="create-status"></p> |
| + </section> |
| + |
| <section id="devices-section"> |
| <h2>Devices</h2> |
| - <p>Devices that can read and update this backup. Revoking one requires a |
| - fresh passkey check.</p> |
| + <p>Devices signed in with your passkey. Revoking one requires a fresh |
| + passkey check.</p> |
| <button id="devices" class="secondary">Show devices</button> |
| <ul class="devices hidden" id="device-list"></ul> |
| <p class="status" id="devices-status"></p> |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 7d7a136..d587a4e 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -1,4 +1,5 @@ |
| -// The home page: create a backup passkey, restore, manage devices. All crypto |
| +// The home page: sign in with a passkey, list your spaces, manage devices. |
| +// (Under the hood a "sign-in" unlocks the encrypted key backup.) All crypto |
| // is client-side (see crypto.js); the WebAuthn ceremonies + blob I/O live in |
| // backup-core.js (shared with the enrolment popup). Status and errors always |
| // render inline in each section — never a floating toast. |
| @@ -85,7 +86,7 @@ async function createBackup() { |
| version: "0", |
| }; |
| await saveVault(); |
| - setStatus("create-status", "Backup created and protected by your new passkey.", "ok"); |
| + setStatus("create-status", "Your Eleven passkey is ready. Spaces you save will appear here.", "ok"); |
| renderVault(); |
| } |
| @@ -98,25 +99,25 @@ async function restore() { |
| state.credId = credId; |
| const got = await fetchBlob(token); |
| if (!got) { |
| - setStatus("restore-status", "Signed in, but there's no backup stored yet.", "error"); |
| + setStatus("restore-status", "Signed in, but no spaces are saved to this passkey yet.", "error"); |
| return; |
| } |
| if (!prf) { |
| - setStatus("restore-status", "Signed in, but this browser didn't return a PRF result, so the backup can't be decrypted here.", "error"); |
| + setStatus("restore-status", "Signed in, but this browser didn't return a PRF result, so your spaces can't be unlocked here.", "error"); |
| return; |
| } |
| const kek = await kekFromPRF(prf); |
| const { keys } = unpackBlob(got.bytes); |
| const wrapped = keys[credId]; |
| if (!wrapped) { |
| - setStatus("restore-status", "This passkey isn't enrolled in the backup's keys.", "error"); |
| + setStatus("restore-status", "This passkey isn't one of this account's keys.", "error"); |
| return; |
| } |
| dekKey = await unwrapDEK(wrapped, kek); |
| state.dek = await exportDEK(dekKey); |
| const opened = await openBlob(got.bytes, dekKey); |
| vault = { obj: opened.obj, keys: opened.keys, version: got.version }; |
| - setStatus("restore-status", "Backup unlocked.", "ok"); |
| + setStatus("restore-status", "Signed in.", "ok"); |
| renderVault(); |
| } |
| @@ -127,13 +128,14 @@ function renderVault() { |
| const servers = vault.obj.servers || []; |
| if (!servers.length) { |
| const li = document.createElement("li"); |
| - li.textContent = "No servers in this backup yet."; |
| + li.textContent = "No spaces saved yet — save one from its chat page and it'll appear here."; |
| list.append(li); |
| } |
| for (const s of servers) { |
| const li = document.createElement("li"); |
| - const name = document.createElement("span"); |
| + const name = document.createElement("a"); |
| name.className = "name"; |
| + name.href = s.url; |
| name.textContent = s.name || new URL(s.url).host; |
| const url = document.createElement("span"); |
| url.className = "url"; |
| @@ -147,7 +149,7 @@ function renderVault() { |
| async function showDevices() { |
| if (!state.token) { |
| - setStatus("devices-status", "Restore (or create) first — the device list needs a signed-in session.", "error"); |
| + setStatus("devices-status", "Sign in first — the device list needs a signed-in session.", "error"); |
| return; |
| } |
| const { devices } = await apiJSON("/api/devices", { |
| @@ -235,7 +237,7 @@ if (state.token && state.dek) { |
| dekKey = await importDEK(state.dek); |
| const opened = await openBlob(got.bytes, dekKey); |
| vault = { obj: opened.obj, keys: opened.keys, version: got.version }; |
| - setStatus("restore-status", "Backup unlocked (session).", "ok"); |
| + setStatus("restore-status", "Signed in (this session).", "ok"); |
| renderVault(); |
| } catch { /* token or DEK went stale; the buttons still work */ } |
| })(); |