| 1 | // The vault console: unlock, then list, create, edit and delete items. |
| 2 | // |
| 3 | // Client-shape by force. Sealing has to happen where the keys are, so a |
| 4 | // server-rendered vault is not a thing that can exist — the server has never |
| 5 | // seen a title and could not render one if it wanted to. |
| 6 | // |
| 7 | // Every node here is built rather than assigned: no innerHTML, ever, on |
| 8 | // content that came out of a vault. That is not caution about our own data, |
| 9 | // it is that an item's title is whatever a person or an importer put there. |
| 10 | |
| 11 | import { net } from "../crypto/net.js"; |
| 12 | import { items as itemStore } from "../crypto/items.js"; |
| 13 | import { accountKeys, unlock } from "../crypto/vaultkeys.js"; |
| 14 | import { FIELDS } from "../crypto/items.js"; |
| 15 | import { wireSharing } from "./vaultshare.js"; |
| 16 | |
| 17 | function el(tag, attrs = {}, ...children) { |
| 18 | const node = document.createElement(tag); |
| 19 | for (const [k, v] of Object.entries(attrs)) { |
| 20 | if (k === "class") node.className = v; |
| 21 | else if (k.startsWith("on")) node.addEventListener(k.slice(2), v); |
| 22 | else node.setAttribute(k, v); |
| 23 | } |
| 24 | for (const c of children) { |
| 25 | if (c == null) continue; |
| 26 | node.append(typeof c === "string" ? document.createTextNode(c) : c); |
| 27 | } |
| 28 | return node; |
| 29 | } |
| 30 | |
| 31 | const LABELS = { |
| 32 | title: "Name", url: "Website", username: "Username", |
| 33 | password: "Password", notes: "Notes", |
| 34 | }; |
| 35 | |
| 36 | function fieldRow(name, value) { |
| 37 | return el("p", {}, |
| 38 | el("label", { for: `f-${name}` }, LABELS[name]), |
| 39 | name === "notes" |
| 40 | ? el("textarea", { id: `f-${name}`, name }) |
| 41 | : el("input", { |
| 42 | id: `f-${name}`, name, |
| 43 | type: name === "password" ? "password" : "text", |
| 44 | autocomplete: "off", |
| 45 | })); |
| 46 | } |
| 47 | |
| 48 | function readForm(form) { |
| 49 | const out = {}; |
| 50 | for (const f of FIELDS) out[f] = form.querySelector(`[name=${f}]`).value; |
| 51 | return out; |
| 52 | } |
| 53 | |
| 54 | function fillForm(form, fields) { |
| 55 | for (const f of FIELDS) form.querySelector(`[name=${f}]`).value = fields[f] || ""; |
| 56 | } |
| 57 | |
| 58 | // view holds the whole screen's state in one place, so there is one render |
| 59 | // path rather than thirty callers each patching a corner of the DOM. The |
| 60 | // family's evidence for why: Eleven's hand-rolled shell reached 8,722 lines |
| 61 | // with one render function called from 31 places. |
| 62 | function view(root, store) { |
| 63 | let list = []; |
| 64 | let editing = null; |
| 65 | |
| 66 | const status = el("p", { "data-status": "", "aria-live": "polite" }); |
| 67 | const form = el("form", { "data-item-form": "" }, |
| 68 | ...FIELDS.map((f) => fieldRow(f, "")), |
| 69 | el("p", {}, |
| 70 | el("button", { type: "submit", "data-save": "" }, "Save"), |
| 71 | " ", |
| 72 | el("button", { type: "button", "data-cancel": "" }, "Cancel"))); |
| 73 | const table = el("ul", { "data-items": "" }); |
| 74 | |
| 75 | function say(text) { |
| 76 | status.replaceChildren(document.createTextNode(text)); |
| 77 | } |
| 78 | |
| 79 | function draw() { |
| 80 | table.replaceChildren(...list.map((item) => |
| 81 | el("li", { "data-item": item.idHex }, |
| 82 | el("strong", {}, item.fields.title || "(no name)"), |
| 83 | item.fields.username ? el("span", {}, " — " + item.fields.username) : null, |
| 84 | " ", |
| 85 | el("button", { |
| 86 | type: "button", "data-edit": item.idHex, |
| 87 | onclick: () => { |
| 88 | editing = item; |
| 89 | fillForm(form, item.fields); |
| 90 | say(`Editing ${item.fields.title || "(no name)"}.`); |
| 91 | }, |
| 92 | }, "Edit"), |
| 93 | " ", |
| 94 | el("button", { |
| 95 | type: "button", "data-delete": item.idHex, |
| 96 | onclick: async () => { |
| 97 | await store.remove(item); |
| 98 | list = list.filter((i) => i.idHex !== item.idHex); |
| 99 | if (editing && editing.idHex === item.idHex) editing = null; |
| 100 | draw(); |
| 101 | say("Deleted."); |
| 102 | }, |
| 103 | }, "Delete")))); |
| 104 | } |
| 105 | |
| 106 | form.addEventListener("submit", async (e) => { |
| 107 | e.preventDefault(); |
| 108 | const fields = readForm(form); |
| 109 | if (editing) { |
| 110 | const updated = await store.update(editing, fields); |
| 111 | list = list.map((i) => (i.idHex === updated.idHex ? updated : i)); |
| 112 | editing = null; |
| 113 | say("Saved."); |
| 114 | } else { |
| 115 | list = [...list, await store.create(fields)]; |
| 116 | say("Added."); |
| 117 | } |
| 118 | fillForm(form, {}); |
| 119 | draw(); |
| 120 | }); |
| 121 | |
| 122 | form.querySelector("[data-cancel]").addEventListener("click", () => { |
| 123 | editing = null; |
| 124 | fillForm(form, {}); |
| 125 | say(""); |
| 126 | }); |
| 127 | |
| 128 | root.replaceChildren(status, form, table); |
| 129 | return { |
| 130 | async load() { |
| 131 | list = await store.list(); |
| 132 | draw(); |
| 133 | say(list.length === 0 ? "No items yet." : `${list.length} item(s).`); |
| 134 | }, |
| 135 | }; |
| 136 | } |
| 137 | |
| 138 | // vaultLabel names a vault on the picker. The name comes out of the keyset, |
| 139 | // so the server has never seen it and could not label this list if it tried. |
| 140 | function vaultLabel(v) { |
| 141 | const name = v.name || "Vault"; |
| 142 | return v.owned ? name : `${name} (shared with you)`; |
| 143 | } |
| 144 | |
| 145 | // showVaults draws the picker and opens one vault. |
| 146 | // |
| 147 | // Before sharing there was only ever vaults[0], and picking it silently was |
| 148 | // indistinguishable from picking correctly. With a shared vault in the list |
| 149 | // that stopped being true: a member's own empty vault sorts first, so the |
| 150 | // vault they were actually given never appeared. A browser walk caught it, |
| 151 | // which is the argument for having one. |
| 152 | async function showVaults(root, opened) { |
| 153 | const picker = document.querySelector("[data-vault-picker]"); |
| 154 | const sharingPanel = document.querySelector("[data-sharing]"); |
| 155 | |
| 156 | async function openOne(vault) { |
| 157 | const store = itemStore(net, vault); |
| 158 | await view(root, store).load(); |
| 159 | // Somebody else's vault prints on different stock — the cloakroom rule, |
| 160 | // not a badge saying "shared". Whose sheet you are writing on is the |
| 161 | // thing you want to know before you type, not after. |
| 162 | root.toggleAttribute("data-shared", !vault.owned); |
| 163 | // Sharing needs the vault key — it seals the keyset to somebody, and |
| 164 | // removing somebody re-seals every item — so it only appears once a |
| 165 | // vault is actually open, and it is rewired per vault. |
| 166 | if (sharingPanel) { |
| 167 | sharingPanel.hidden = false; |
| 168 | wireSharing(document, opened, vault, store); |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | if (picker) { |
| 173 | picker.replaceChildren(...opened.vaults.map((v, i) => |
| 174 | el("option", { value: String(i) }, vaultLabel(v)))); |
| 175 | // Only worth showing when there is a choice to make. |
| 176 | picker.parentElement.hidden = opened.vaults.length < 2; |
| 177 | picker.addEventListener("change", async () => { |
| 178 | await openOne(opened.vaults[Number(picker.value)]); |
| 179 | }); |
| 180 | } |
| 181 | await openOne(opened.vaults[0]); |
| 182 | } |
| 183 | |
| 184 | async function main() { |
| 185 | const root = document.querySelector("[data-vault]"); |
| 186 | if (!root) return; |
| 187 | |
| 188 | const gate = document.querySelector("[data-unlock]"); |
| 189 | const bootstrap = await net.get("/api/bootstrap"); |
| 190 | const keys = accountKeys(window.localStorage); |
| 191 | |
| 192 | // Signing in is not unlocking. The session proves who you are; the keys |
| 193 | // come from a wrap the server cannot open, so this asks again. |
| 194 | gate.addEventListener("submit", async (e) => { |
| 195 | e.preventDefault(); |
| 196 | const passphrase = gate.querySelector("[name=passphrase]").value; |
| 197 | const accountKey = keys.get(bootstrap.account_id_hex); |
| 198 | const box = gate.querySelector("[data-status]"); |
| 199 | if (!accountKey) { |
| 200 | // Never a dead end. This browser can still get the key three ways, and |
| 201 | // the screen that offers all three is one link away. |
| 202 | box.replaceChildren( |
| 203 | document.createTextNode( |
| 204 | "This device does not have your Account Key yet, so it cannot open " + |
| 205 | "the vault. "), |
| 206 | Object.assign(document.createElement("a"), { |
| 207 | href: "/device", |
| 208 | textContent: "Give it the key", |
| 209 | }), |
| 210 | document.createTextNode( |
| 211 | " — from another signed-in device, or from your printed card.")); |
| 212 | return; |
| 213 | } |
| 214 | box.replaceChildren(document.createTextNode("Unlocking…")); |
| 215 | try { |
| 216 | const opened = await unlock(bootstrap, passphrase, accountKey); |
| 217 | gate.hidden = true; |
| 218 | root.hidden = false; |
| 219 | await showVaults(root, opened); |
| 220 | } catch (err) { |
| 221 | box.replaceChildren(document.createTextNode(err.message)); |
| 222 | } |
| 223 | }); |
| 224 | } |
| 225 | |
| 226 | main(); |
| 227 | |