| 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); |