| index aafce43..c19b053 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -195,11 +195,16 @@ async function saveVault() { |
| // keeps its tombstone and wins over the other writer's copy). |
| const removed = mergeRemoved(opened.obj.removed, vault.obj.removed) |
| .filter((t) => !vault.obj.servers.some((s) => s.url === t.url)); |
| + // Method labels merge the same way: the other writer's base, ours |
| + // overlaid (last-writer-wins per key; a raced deletion may resurrect a |
| + // label โ they're a nicety, that's fine at this stage). |
| + const methods = { ...(opened.obj.methods || {}), ...(vault.obj.methods || {}) }; |
| const obj = { |
| ...opened.obj, |
| servers: [...merged.values()].filter((s) => !removed.some((t) => t.url === s.url)), |
| }; |
| if (removed.length) obj.removed = removed; else delete obj.removed; |
| + if (Object.keys(methods).length) obj.methods = methods; else delete obj.methods; |
| vault = { obj, keys: opened.keys, version: cur.version }; |
| } |
| throw new Error("could not save after retry"); |
| @@ -1179,6 +1184,43 @@ function methodAdded(ts) { |
| return "added " + d.toLocaleDateString(undefined, opts); |
| } |
| |
| +// ---- method labels -------------------------------------------------------------- |
| + |
| +// A method row's human label ("personal", the email address) is E2EE state, |
| +// never server metadata: vault.obj.methods = { "<kind>/<id>": { label } }, |
| +// keyed the way the anchors listing names rows, riding the sealed blob like |
| +// space names do. Sealed sessions (no DEK) neither show nor offer labels. |
| +let editingLabel = ""; |
| + |
| +function canLabel() { return !!(vault && dekKey); } |
| + |
| +function methodLabel(key) { |
| + const m = vault && vault.obj && vault.obj.methods; |
| + return (m && m[key] && m[key].label) || ""; |
| +} |
| + |
| +async function setMethodLabel(key, label) { |
| + if (!canLabel()) return; |
| + const m = { ...(vault.obj.methods || {}) }; |
| + if (label) m[key] = { label }; else delete m[key]; |
| + if (Object.keys(m).length) vault.obj.methods = m; else delete vault.obj.methods; |
| + await saveVault(); |
| +} |
| + |
| +// labelNewEmail stores a just-verified address as its anchor's label. The |
| +// client can't compute the anchor id (that's the server's peppered blind |
| +// index), so diff the listing: the email id that wasn't there before the |
| +// add is the one just created. |
| +async function labelNewEmail(email) { |
| + if (!canLabel()) return; |
| + const before = new Set((anchors || []).filter((x) => x.kind === "email").map((x) => x.id)); |
| + try { |
| + const r = await apiJSON("/api/anchors", { headers: { Authorization: "Bearer " + state.token } }); |
| + const fresh = (r.anchors || []).filter((x) => x.kind === "email" && !before.has(x.id)); |
| + if (fresh.length === 1) { await setMethodLabel("email/" + fresh[0].id, email); renderMethods(); } |
| + } catch { /* the label is a nicety โ the anchor itself is already in */ } |
| +} |
| + |
| function renderMethods() { |
| closeRowMenu(false); |
| const box = $("methods-list"); |
| @@ -1224,8 +1266,9 @@ function methodRow(a) { |
| li.dataset.key = key; |
| const def = METHOD_KINDS.find((k) => k.kind === a.kind); |
| const label = def ? def.name : a.kind; |
| + const tag = methodLabel(key); // "" when unlabelled, or the vault is sealed |
| const added = methodAdded(a.created_at); |
| - const longName = label + (added ? ", " + added : ""); // for the aria labels |
| + const longName = label + (tag ? ", " + tag : "") + (added ? ", " + added : ""); // for the aria labels |
| |
| const main = document.createElement("div"); |
| main.className = "method-main"; |
| @@ -1233,10 +1276,12 @@ function methodRow(a) { |
| name.className = "method-name"; |
| name.textContent = label; |
| main.append(name); |
| - if (added) { |
| + if (editingLabel === key) { |
| + main.append(labelEditor(key, tag, longName)); |
| + } else if (tag || added) { |
| const sub = document.createElement("span"); |
| sub.className = "method-added"; |
| - sub.textContent = added; |
| + sub.textContent = tag && added ? tag + " ยท " + added : tag || added; |
| main.append(sub); |
| } |
| li.append(main); |
| @@ -1266,12 +1311,50 @@ function methodRow(a) { |
| focusMethodRow(key, "button"); |
| }); |
| li.append(yes, no); |
| - } else { |
| + } else if (editingLabel !== key) { |
| li.append(rowKebab(key, def, longName, a)); |
| } |
| return li; |
| } |
| |
| +// The inline label editor (never a browser prompt): the row's sub-line |
| +// becomes a small form. Enter saves, Escape or Cancel keeps the old label. |
| +function labelEditor(key, current, longName) { |
| + const form = document.createElement("form"); |
| + form.className = "label-edit"; |
| + const input = document.createElement("input"); |
| + input.type = "text"; |
| + input.maxLength = 60; |
| + input.value = current; |
| + input.placeholder = "e.g. personal, work"; |
| + input.setAttribute("aria-label", "Label for " + longName); |
| + const save = document.createElement("button"); |
| + save.type = "submit"; |
| + save.className = "pill"; |
| + save.textContent = "Save"; |
| + const cancel = document.createElement("button"); |
| + cancel.type = "button"; |
| + cancel.className = "pill"; |
| + cancel.textContent = "Cancel"; |
| + cancel.setAttribute("aria-label", "Cancel editing the label for " + longName); |
| + const done = () => { editingLabel = ""; renderMethods(); focusMethodRow(key, ".icon-kebab"); }; |
| + form.addEventListener("submit", async (e) => { |
| + e.preventDefault(); |
| + save.disabled = cancel.disabled = true; |
| + try { |
| + await setMethodLabel(key, input.value.trim()); |
| + done(); |
| + } catch { |
| + save.disabled = cancel.disabled = false; |
| + methodsStatus("Couldnโt save the label โ try again.", "error"); |
| + } |
| + }); |
| + cancel.addEventListener("click", done); |
| + input.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.stopPropagation(); done(); } }); |
| + form.append(input, save, cancel); |
| + return form; |
| +} |
| + |
| // rowKebab is the row's own menu โ the account kebab's vocabulary scaled |
| // down: aria-haspopup/expanded, focus onto the first item, Escape and |
| // outside-click both close (the shared manager keeps one open at a time). |
| @@ -1302,6 +1385,21 @@ function rowKebab(key, def, longName, a) { |
| add.addEventListener("click", (e) => { closeRowMenu(false); def.start(e); }); |
| menu.append(add); |
| } |
| + if (canLabel()) { |
| + const lab = document.createElement("button"); |
| + lab.type = "button"; |
| + lab.setAttribute("role", "menuitem"); |
| + lab.textContent = methodLabel(key) ? "Edit label" : "Add a label"; |
| + lab.setAttribute("aria-label", (methodLabel(key) ? "Edit the label for " : "Add a label for ") + longName); |
| + lab.addEventListener("click", () => { |
| + closeRowMenu(false); |
| + editingLabel = key; |
| + armedRemove = ""; |
| + renderMethods(); |
| + focusMethodRow(key, "input"); |
| + }); |
| + menu.append(lab); |
| + } |
| const rm = document.createElement("button"); |
| rm.type = "button"; |
| rm.setAttribute("role", "menuitem"); |
| @@ -1311,6 +1409,7 @@ function rowKebab(key, def, longName, a) { |
| rm.addEventListener("click", () => { |
| closeRowMenu(false); |
| armedRemove = key; |
| + editingLabel = ""; |
| renderMethods(); |
| focusMethodRow(key, "button.danger-ink"); // the are-you-sure's Remove |
| }); |
| @@ -1381,6 +1480,7 @@ async function doRemoveAnchor(a, proof) { |
| headers: { "X-Ceremony-Proof": proof }, |
| }); |
| methodsStatus("Removed.", "ok"); |
| + setMethodLabel(a.kind + "/" + a.id, "").catch(() => { /* orphaned label โ harmless */ }); |
| } catch (e) { |
| methodsStatus((e && e.message) || "Couldn't remove it.", "error"); |
| } |
| @@ -1611,6 +1711,7 @@ async function finishEmailAdd(email, code, proof) { |
| body: JSON.stringify({ email, code, link: true }), |
| }); |
| if (res.ok) { |
| + labelNewEmail(email); // fire-and-forget: the address becomes the row's E2EE label |
| backToMethodsList("Email added โ you can sign in with it now.", "ok"); |
| return; |
| } |
| @@ -1896,6 +1997,10 @@ async function combineUnlock() { |
| .filter((t) => !(vault.obj.servers || []).some((sv) => sv.url === t.url)); |
| vault.obj.servers = [...merged.values()].filter((sv) => !removed.some((t) => t.url === sv.url)); |
| if (removed.length) vault.obj.removed = removed; else delete vault.obj.removed; |
| + // Method labels ride the union too: absorbed first, survivor overlays โ |
| + // the moved-over anchors keep the names their old account gave them. |
| + const labels = { ...(opened.obj.methods || {}), ...(vault.obj.methods || {}) }; |
| + if (Object.keys(labels).length) vault.obj.methods = labels; |
| const movedSpaces = vault.obj.servers.length - before; |
| // The absorbed passkey just proved AND unlocked โ after the merge it |
| // signs into the survivor, so wrap the survivor's DEK for it too: it |