| index 4615acc..3347837 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -1,15 +1,17 @@ |
| -// 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. |
| +// 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. |
| +// |
| +// "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, |
| sealBlob, unpackBlob, openBlob, |
| } from "/static/crypto.js"; |
| import { |
| - apiJSON, runRegister, runLogin, fetchBlob, putBlob, |
| + apiJSON, beginLogin, beginRegister, runRegister, runLogin, fetchBlob, putBlob, |
| } from "/static/backup-core.js"; |
| |
| // ---- session state ---------------------------------------------------------- |
| @@ -31,11 +33,39 @@ let dekKey = null; // the imported CryptoKey |
| |
| const $ = (id) => document.getElementById(id); |
| |
| -function setStatus(id, msg, kind) { |
| - const el = $(id); |
| +function setStatus(msg, kind) { |
| + const el = $("status"); |
| el.textContent = msg || ""; |
| el.className = "status" + (kind ? " " + kind : ""); |
| } |
| +function vaultStatus(msg, kind) { |
| + const el = $("vault-status"); |
| + el.textContent = msg || ""; |
| + el.className = "status" + (kind ? " " + kind : ""); |
| +} |
| +function spaceStatus(msg, kind) { |
| + const el = $("new-space-status"); |
| + el.textContent = msg || ""; |
| + el.className = "status" + (kind ? " " + kind : ""); |
| +} |
| + |
| +// Whether this home server offers self-serve space creation (set from |
| +// /api/features). The create-a-space controls only appear when it's both |
| +// enabled here and you're signed in. |
| +let spacesEnabled = false; |
| + |
| +// Ceremony options are pre-fetched so the button click reaches |
| +// 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 ---------------------------------------------------------------- |
| |
| @@ -60,16 +90,54 @@ async function saveVault() { |
| throw new Error("could not save after retry"); |
| } |
| |
| -// ---- create ------------------------------------------------------------------- |
| +// ---- sign in ------------------------------------------------------------------ |
| |
| -async function createBackup() { |
| - setStatus("create-status", "Waiting for your passkey…", "busy"); |
| +async function signIn() { |
| + $("signin").disabled = true; |
| + setStatus("Waiting for your passkey…", "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; |
| + 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; |
| + 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; |
| + 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("Signed in.", "ok"); |
| + 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(); |
| + reg = await runRegister(registerBegin); |
| } catch (e) { |
| + registerBegin = null; prefetchRegister(); // spent — refresh for a retry |
| if (e.message === "no-prf") { |
| - setStatus("create-status", "This passkey manager doesn't support the PRF extension, so it can't protect a backup here. Add-device links remain the fallback.", "error"); |
| + 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; |
| @@ -86,48 +154,22 @@ async function createBackup() { |
| version: "0", |
| }; |
| await saveVault(); |
| - setStatus("create-status", "Your Eleven passkey is ready. Spaces you save will appear here.", "ok"); |
| - renderVault(); |
| + setStatus("Your Eleven passkey is ready. Spaces you save will appear here.", "ok"); |
| + showVault(); |
| } |
| |
| -// ---- restore ------------------------------------------------------------------ |
| +// ---- spaces list -------------------------------------------------------------- |
| |
| -async function restore() { |
| - setStatus("restore-status", "Waiting for your passkey…", "busy"); |
| - const { token, credId, prf } = await runLogin(); |
| - state.token = token; |
| - state.credId = credId; |
| - const got = await fetchBlob(token); |
| - if (!got) { |
| - 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 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 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", "Signed in.", "ok"); |
| - renderVault(); |
| -} |
| - |
| -function renderVault() { |
| +function showVault() { |
| + $("newhere").classList.add("hidden"); |
| $("vault").classList.remove("hidden"); |
| + if (spacesEnabled) $("space-section").classList.remove("hidden"); |
| const list = $("server-list"); |
| list.textContent = ""; |
| const servers = vault.obj.servers || []; |
| if (!servers.length) { |
| const li = document.createElement("li"); |
| + li.className = "empty"; |
| li.textContent = "No spaces saved yet — save one from its chat page and it'll appear here."; |
| list.append(li); |
| } |
| @@ -145,65 +187,20 @@ function renderVault() { |
| } |
| } |
| |
| -// ---- devices ------------------------------------------------------------------ |
| - |
| -async function showDevices() { |
| - if (!state.token) { |
| - setStatus("devices-status", "Sign in first — the device list needs a signed-in session.", "error"); |
| - return; |
| - } |
| - const { devices } = await apiJSON("/api/devices", { |
| - headers: { Authorization: "Bearer " + state.token }, |
| - }); |
| - const list = $("device-list"); |
| - list.classList.remove("hidden"); |
| - list.textContent = ""; |
| - for (const d of devices) { |
| - const li = document.createElement("li"); |
| - const meta = document.createElement("div"); |
| - meta.className = "meta"; |
| - meta.textContent = "Device " + d.id.slice(0, 8) + (d.current ? " (this session)" : ""); |
| - const small = document.createElement("small"); |
| - small.textContent = "added " + new Date(d.created_at * 1000).toLocaleDateString() + |
| - " · last seen " + new Date(d.last_seen * 1000).toLocaleDateString(); |
| - meta.append(small); |
| - const btn = document.createElement("button"); |
| - btn.className = "secondary"; |
| - btn.textContent = "Revoke"; |
| - btn.addEventListener("click", () => revokeDevice(d.id).catch(fail("devices-status"))); |
| - li.append(meta, btn); |
| - list.append(li); |
| - } |
| - setStatus("devices-status", devices.length ? "" : "No devices."); |
| -} |
| - |
| -async function revokeDevice(id) { |
| - // Destructive: needs a FRESH ceremony, not the link token — a stolen token |
| - // must never be able to lock you out. |
| - setStatus("devices-status", "Confirm with your passkey to revoke…", "busy"); |
| - const { proof } = await runLogin(); |
| - await apiJSON("/api/devices/" + encodeURIComponent(id), { |
| - method: "DELETE", |
| - headers: { "X-Ceremony-Proof": proof }, |
| - }); |
| - setStatus("devices-status", "Device revoked.", "ok"); |
| - await showDevices(); |
| -} |
| - |
| // ---- create a new space ------------------------------------------------------- |
| |
| // createSpace asks this home server to provision a brand-new Eleven space for |
| // the signed-in user (POST /api/spaces, link-token auth), then folds it into |
| -// the backup so restore always finds the way back. The vault must be unlocked: |
| +// the vault so sign-in always finds the way back. The vault must be unlocked: |
| // that's how we hold the DEK to save the new entry (and the link token to ask). |
| async function createSpace() { |
| if (!vault) { |
| - setStatus("new-space-status", "Unlock your backup first — your new space is saved into it.", "error"); |
| + spaceStatus("Sign in first — your new space is saved into your account.", "error"); |
| return; |
| } |
| const btn = $("new-space"); |
| btn.disabled = true; // one space per click — a double-tap would spend quota twice |
| - setStatus("new-space-status", "Building your space…", "busy"); |
| + spaceStatus("Building your space…", "busy"); |
| try { |
| const res = await fetch("/api/spaces", { |
| method: "POST", |
| @@ -212,11 +209,10 @@ async function createSpace() { |
| const body = await res.json().catch(() => ({})); |
| if (!res.ok) { |
| // Structured errors carry a member-facing message (quota, at-capacity). |
| - setStatus("new-space-status", |
| - body.message || body.error || `Couldn't create a space (HTTP ${res.status}).`, "error"); |
| + spaceStatus(body.message || body.error || `Couldn't create a space (HTTP ${res.status}).`, "error"); |
| return; |
| } |
| - // Save it into the backup (last-writer-wins per url, like the add form). |
| + // Save it into the vault (last-writer-wins per url, like the add form). |
| const url = body.url; |
| const host = new URL(url).host; |
| vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url); |
| @@ -225,12 +221,12 @@ async function createSpace() { |
| try { |
| await saveVault(); |
| } catch { |
| - // The space exists even if the backup write raced; say so but still show it. |
| + // The space exists even if the vault write raced; say so but still show it. |
| saved = false; |
| } |
| - renderVault(); |
| + showVault(); |
| showNewSpaceResult(host, body.setup_link || url, saved); |
| - setStatus("new-space-status", ""); |
| + spaceStatus(""); |
| } finally { |
| btn.disabled = false; |
| } |
| @@ -255,54 +251,71 @@ function showNewSpaceResult(host, openLink, saved) { |
| const note = document.createElement("p"); |
| note.className = "sub"; |
| note.textContent = saved |
| - ? "This one-time link makes you the keyholder. It's saved in your backup below." |
| - : "This one-time link makes you the keyholder. Couldn't save it to the backup just now — add it below to be safe."; |
| + ? "This one-time link makes you the keyholder. It's saved to your account below." |
| + : "This one-time link makes you the keyholder. Couldn't save it just now — add it below to be safe."; |
| |
| box.append(ready, a, note); |
| } |
| |
| -// ---- add-server form ---------------------------------------------------------- |
| +// ---- add-space form ----------------------------------------------------------- |
| |
| async function addServer(e) { |
| e.preventDefault(); |
| const url = $("add-url").value.trim(); |
| const name = $("add-name").value.trim(); |
| if (!url || !vault) return; |
| - try { new URL(url); } catch { setStatus("vault-status", "That doesn't look like a URL.", "error"); return; } |
| + try { new URL(url); } catch { vaultStatus("That doesn't look like a URL.", "error"); return; } |
| vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url); |
| vault.obj.servers.push(name ? { url, name } : { url }); |
| - setStatus("vault-status", "Saving…", "busy"); |
| + vaultStatus("Saving…", "busy"); |
| await saveVault(); |
| $("add-url").value = ""; |
| $("add-name").value = ""; |
| - setStatus("vault-status", "Saved.", "ok"); |
| - renderVault(); |
| + vaultStatus("Saved.", "ok"); |
| + showVault(); |
| } |
| |
| // ---- wiring ------------------------------------------------------------------- |
| |
| -const fail = (id) => (err) => { |
| - // A dismissed passkey sheet is a user choice, not an error worth shouting about. |
| - if (err.name === "NotAllowedError") setStatus(id, "Passkey prompt was dismissed."); |
| - else setStatus(id, err.message || String(err), "error"); |
| +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. |
| + if (err && err.name === "NotAllowedError") { |
| + setStatus("No passkey was used. If this device is new, create your Eleven passkey below.", ""); |
| + } 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("create-status", "This browser doesn't support passkeys.", "error"); |
| - $("create").disabled = true; |
| - $("restore").disabled = true; |
| + setStatus("This browser doesn't support passkeys.", "error"); |
| + $("signin").disabled = true; |
| } else { |
| - $("create").addEventListener("click", () => createBackup().catch(fail("create-status"))); |
| - $("restore").addEventListener("click", () => restore().catch(fail("restore-status"))); |
| + prefetchLogin(); |
| + $("signin").addEventListener("click", () => signIn().catch(onSignInError)); |
| + $("create").addEventListener("click", () => createPasskey().catch(onCreateError)); |
| } |
| -$("devices").addEventListener("click", () => showDevices().catch(fail("devices-status"))); |
| -$("add-form").addEventListener("submit", (e) => addServer(e).catch(fail("vault-status"))); |
| -$("new-space").addEventListener("click", () => createSpace().catch(fail("new-space-status"))); |
| +$("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"); |
| +})); |
| +$("add-form").addEventListener("submit", (e) => addServer(e).catch((err) => vaultStatus(err.message || String(err), "error"))); |
| |
| -// Show the "create a space" section only where this home server offers it — |
| -// other people run their own home servers without a provisioner. |
| +// Learn whether this home server offers self-serve space creation. If we're |
| +// already signed in (session rehydrate below), reveal the controls too. |
| apiJSON("/api/features") |
| - .then((f) => { if (f.spaces) $("space-section").classList.remove("hidden"); }) |
| + .then((f) => { spacesEnabled = !!f.spaces; if (spacesEnabled && vault) $("space-section").classList.remove("hidden"); }) |
| .catch(() => { /* older home server without the endpoint: leave it hidden */ }); |
| |
| // Rehydrate a same-tab session (e.g. a reload): the DEK and token are still in |
| @@ -315,8 +328,8 @@ 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", "Signed in (this session).", "ok"); |
| - renderVault(); |
| - } catch { /* token or DEK went stale; the buttons still work */ } |
| + setStatus("Signed in (this session).", "ok"); |
| + showVault(); |
| + } catch { /* token or DEK went stale; the button still works */ } |
| })(); |
| } |