eleven / messenger Public

home: minimal "Sign in with Eleven" landing (#312)

Strip the home landing back to what a member actually needs: the Eleven mark,
one "Sign in with Eleven" button, and a grey reassurance line ("No signup
required, we don't store any personal details. We can't spam what we don't
have."). The signed-out page is now just that.

- Collapse the "Sign in" / "New here?" split into one button. They're different
  WebAuthn ceremonies (assertion vs registration), so create can't literally be
  the same call — instead it's a quiet fallback link revealed only if sign-in
  finds no passkey on this device.
- Everything else (your spaces, create-a-space, add-by-URL) moves behind
  sign-in, revealed in the vault card once you're in.
- Drop the Devices section from the landing.
- Preserve the self-serve "Create a new space" provisioner (POST /api/spaces,
  /api/features gate) — now shown inside the signed-in vault.
- Prefetch login/register options so the button click reaches
  navigator.credentials.* within the gesture (Safari activation).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 91c56a9a51614af665d3c96fbab8c49ea8cc2fbf parent 97eddf4
3 files changed, +216 −206
  • home/web/pages/index.html +78 −64
  • home/web/static/home.css +0 −17
  • home/web/static/home.js +138 −125
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 76b8670..75b89d6 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -8,79 +8,93 @@
<title>Sign in with Eleven</title>
<link rel="stylesheet" href="/static/home.css">
<style>
- /* 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);
+ /* The landing is a single centered card: the Eleven mark, one button, and a
+ reassuring line. Your spaces appear only once you've signed in. */
+ .brand { display: flex; flex-direction: column; align-items: center; gap: 10px; margin-bottom: 22px; }
+ .brand-mark {
+ width: 64px; height: 64px; border-radius: 18px;
+ display: grid; place-items: center;
+ background: var(--accent); color: var(--accent-ink);
+ font-family: var(--font-display); font-weight: 800; font-size: 28px;
+ letter-spacing: -0.03em;
+ }
+ .brand-word {
+ font-family: var(--font-display); font-weight: 800;
+ font-size: 26px; letter-spacing: -0.02em;
}
- 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 {
- display: flex; align-items: center; gap: .75rem; padding: .5rem 0;
- border-top: 1px solid var(--border);
- font-size: .9rem;
+ #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;
}
- ul.devices li .meta { flex: 1; }
- ul.devices li .meta small { display: block; color: var(--muted); }
- ul.devices li button { padding: 6px 12px; font-size: 13px; }
- .addform { display: flex; gap: .5rem; margin-top: .75rem; flex-wrap: wrap; }
+ /* Your spaces, revealed after sign-in. */
+ #vault { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); text-align: left; }
+ #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 {
+ display: flex; flex-direction: column; gap: 2px;
+ padding: .55rem 0; border-top: 1px solid var(--border);
+ }
+ ul.servers li:first-child { border-top: none; }
+ ul.servers li a.name { font-weight: 600; color: var(--ink); text-decoration: none; }
+ ul.servers li a.name:hover { text-decoration: underline; }
+ ul.servers li .url { color: var(--muted); font-size: .8rem; overflow-wrap: anywhere; }
+ ul.servers li.empty { color: var(--muted); font-size: .9rem; }
+ #space-section { margin-top: 16px; }
+ #space-section p.lead { color: var(--muted); font-size: .85rem; margin: 0 0 8px; }
+ #new-space { width: 100%; }
+ .addform { display: flex; gap: .5rem; margin-top: 16px; flex-wrap: wrap; }
.addform input { flex: 1; min-width: 8rem; }
- #vault h3 { font-size: 14px; margin: 16px 0 0; font-family: var(--font-display); }
+ .addform-label { font-size: .8rem; color: var(--muted); margin: 0; width: 100%; }
</style>
</head>
-<body class="manage">
-<main>
- <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>
+<body>
+<main class="card">
+ <div class="brand">
+ <div class="brand-mark" aria-hidden="true">11</div>
+ <div class="brand-word">Eleven</div>
+ </div>
- <section id="restore-section">
- <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">
- <input id="add-url" placeholder="https://family.luan.chat" autocomplete="off" required>
- <button type="submit" class="secondary">Add</button>
- </form>
- <p class="status" id="vault-status"></p>
- </div>
- </section>
+ <div class="actions">
+ <button id="signin">Sign in with Eleven</button>
+ </div>
- <section id="space-section" class="hidden">
- <h2>Create a new space</h2>
- <p>Start your own private Eleven space — a group that's yours. You get a
- one-time link that makes you its keyholder, and it's saved to your
- passkey so you can sign back in from any device.</p>
- <button id="new-space">Create a new space</button>
- <p class="status" id="new-space-status"></p>
- <div id="new-space-result" class="hidden"></div>
- </section>
+ <p class="reassure">No signup required, we don't store any personal details.
+ We can't spam what we don't have.</p>
- <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>
+ <!-- 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>
+
+ <!-- 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>
+ <ul class="servers" id="server-list"></ul>
+
+ <!-- Create a new space — only where this home server offers provisioning
+ (revealed by /api/features) and only once you're signed in. -->
+ <div id="space-section" class="hidden">
+ <p class="lead">Start your own private space — a group that's yours,
+ saved to your passkey.</p>
+ <button id="new-space">Create a new space</button>
+ <p class="status" id="new-space-status"></p>
+ <div id="new-space-result" class="hidden"></div>
+ </div>
- <section id="devices-section">
- <h2>Devices</h2>
- <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>
- </section>
+ <form class="addform" id="add-form">
+ <p class="addform-label">Or add a space you already have a link for:</p>
+ <input id="add-name" placeholder="Name" autocomplete="off">
+ <input id="add-url" placeholder="https://family.luan.chat" autocomplete="off" required>
+ <button type="submit" class="secondary">Add</button>
+ </form>
+ <p class="status" id="vault-status"></p>
+ </div>
</main>
<script type="module" src="/static/home.js"></script>
</body>
diff --git a/home/web/static/home.css b/home/web/static/home.css
index a7287d7..7aab638 100644
--- a/home/web/static/home.css
+++ b/home/web/static/home.css
@@ -129,23 +129,6 @@ input:focus-visible { outline: 2px solid var(--accent); border-color: transparen
.badge { transition: none; }
}
-/* The manage page (/) is a top-aligned column of section cards, not a single
- centered ceremony card. */
-body.manage { display: block; }
-body.manage main { max-width: 30rem; margin: 0 auto; }
-body.manage h1 { font-family: var(--font-display); font-size: 24px; margin: 8px 0 4px; }
-body.manage .sub { color: var(--muted); margin: 0 0 20px; font-size: 15px; }
-body.manage section {
- background: var(--panel);
- border: 1px solid var(--border);
- border-radius: 18px;
- padding: 18px;
- margin-bottom: 14px;
- box-shadow: 0 6px 20px var(--shadow);
-}
-body.manage section h2 { font-size: 16px; margin: 0 0 4px; font-family: var(--font-display); }
-body.manage section p { margin: 0 0 12px; font-size: 14px; color: var(--muted); }
-
/* The freshly-created space's "open it" link reads as a primary button. */
#new-space-result { margin-top: 12px; }
#new-space-result p { margin: 0 0 10px; }
diff --git a/home/web/static/home.js b/home/web/static/home.js
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 */ }
})();
}