// Desktop UI. All vault logic runs in Rust (src-tauri → kv-ffi); this layer only renders. import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; interface AppState { has_account: boolean; locked: boolean; remembers_secret_key: boolean; ipc_error: string | null; idle_minutes: number } interface ItemSummary { id: string; title: string; username: string | null; urls: string[]; favorite: boolean; has_totp: boolean } interface ItemDto { id: string; title: string; username: string | null; password: string | null; urls: string[]; totp: string | null; notes: string | null } interface PairingView { id: string; name: string; caller: string; words: string[] } interface Pairing { id: string; name: string; caller: string; words: string[]; created_at: number } interface SaveView { id: string; origin: string; username: string; update: boolean } interface SyncStatus { server_url: string | null; pins: string[]; logged_in: boolean; syncing: boolean; last_sync_at: number | null; last_error: string | null; pending_approval: boolean; pending_devices: number; integrity_alarm: string | null; device_fingerprint: string | null; key_storage: string | null; key_warning: string | null; restored_items: number; changes: number; } interface DeviceView { device_id: string; name: string; platform: string; approved: boolean; revoked: boolean; current: boolean; created_at: string; key_fingerprint: string } interface SetupView { server_url: string; server_host: string; account_id: string; preapproved: boolean } interface JoinView { qr_svg: string; qr_text: string; expires_at: number } interface JoinPollView { code: string | null; setup: SetupView | null } interface AddDeviceView { qr_svg: string; expires_at: number } interface JoinConfirmView { code: string; device_name: string; platform: string; key_fingerprint: string } const app = document.getElementById("app")!; const overlay = document.getElementById("overlay")!; const modal = document.getElementById("modal")!; function el(tag: K, props: Partial & Record = {}, ...children: (Node | string)[]): HTMLElementTagNameMap[K] { const e: HTMLElementTagNameMap[K] = Object.assign(document.createElement(tag), props); e.append(...children); return e; } const errText = (e: unknown) => (typeof e === "string" ? e : e instanceof Error ? e.message : String(e)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /// "ab12cd34…" → "ab12 cd34 …" so two screens are easy to compare. const groups = (hex: string) => hex.replace(/(.{4})/g, "$1 ").trim(); function countdown(expiresAt: number, onExpire: () => void): HTMLElement { const span = el("span", { className: "muted" }); const tick = () => { if (!span.isConnected && span.dataset.started) return; span.dataset.started = "1"; const left = Math.max(0, Math.round(expiresAt - Date.now() / 1000)); span.textContent = `Expires in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")}`; if (left === 0) onExpire(); else setTimeout(tick, 1000); }; queueMicrotask(tick); return span; } function ago(unix: number): string { const s = Math.max(0, Math.round(Date.now() / 1000 - unix)); if (s < 60) return "just now"; if (s < 3600) return `${Math.floor(s / 60)} min ago`; if (s < 86400) return `${Math.floor(s / 3600)} h ago`; return new Date(unix * 1000).toLocaleString(); } /// Bumped on every full render so polling loops of a screen that is gone stop. let screen = 0; let lastChanges = -1; async function render() { screen++; const state = await invoke("state"); app.replaceChildren(); if (!state.has_account) await renderOnboarding(); else if (state.locked) renderUnlock(state); else await renderVault(state); await renderPrompts(state); } // ------------------------------------------------------------------ onboarding (docs/10) type Tab = "phone" | "kit" | "paste" | "create"; async function renderOnboarding(tab: Tab = "phone") { const status = await invoke("sync_status"); const body = el("div", { className: "grid" }); const tabs: [Tab, string][] = [["phone", "Use my phone"], ["kit", "Emergency Kit"], ["paste", "Paste setup code"], ["create", "New account"]]; const nav = el("div", { className: "tabs" }, ...tabs.map(([t, label]) => el("button", { textContent: label, className: t === tab ? "tab sel" : "tab", onclick: async () => { await invoke("join_cancel"); app.replaceChildren(); screen++; await renderOnboarding(t); }, }))); const card = el("div", { className: "card wide" }, el("h2", { textContent: "Set up Kestrove on this computer" }), nav, body); if (status.key_warning) card.append(el("p", { className: "warn", textContent: status.key_warning })); app.append(el("div", { className: "center" }, card)); if (tab === "phone") phonePanel(body, status); else if (tab === "kit") kitPanel(body); else if (tab === "paste") pastePanel(body); else createPanel(body, status); } function serverInput(status: SyncStatus): HTMLInputElement { return el("input", { type: "url", placeholder: "https://kestrove.example.com", value: status.server_url ?? "", spellcheck: false }); } function phonePanel(body: HTMLElement, status: SyncStatus) { const url = serverInput(status); const err = el("div", { className: "err" }); const start = el("button", { textContent: "Show QR code", className: "primary" }); body.append( el("p", { className: "muted", textContent: "Your phone sends this computer everything it needs. You'll only type your master password." }), el("label", { className: "grid" }, "Your Kestrove server", url), start, err, ); start.onclick = async () => { start.disabled = true; err.textContent = ""; try { const join = await invoke("join_start", { serverUrl: url.value }); showJoin(body, join); } catch (e) { err.textContent = errText(e); start.disabled = false; } }; url.onkeydown = (e) => e.key === "Enter" && start.click(); if (!url.value) url.focus(); } function showJoin(body: HTMLElement, join: JoinView) { const mine = screen; const codeBox = el("div", { className: "code muted", textContent: "Waiting for your phone…" }); const err = el("div", { className: "err" }); const text = el("textarea", { readOnly: true, className: "mono small", rows: 3, value: join.qr_text }); const copy = el("button", { textContent: "Copy", onclick: () => copyText(text) }); const cancel = el("button", { textContent: "Cancel", onclick: async () => { await invoke("join_cancel"); await render(); }, }); body.replaceChildren( el("ol", { className: "steps" }, el("li", { textContent: "On your phone, open Kestrove → Settings → Add a computer." }), el("li", { textContent: "Scan this code." }), el("li", { textContent: "Check that both screens show the same 6 digits, then approve on the phone." })), el("img", { src: join.qr_svg, alt: "QR code for your phone", className: "qr" }), el("div", { className: "row" }, countdown(join.expires_at, () => { if (mine === screen) err.textContent = "The code expired. Cancel and start again."; })), codeBox, el("details", {}, el("summary", { textContent: "No phone? Approve from another computer" }), el("p", { className: "muted", textContent: "On a computer that is already signed in, open Settings → Devices → Approve a computer, and paste this join code:" }), text, el("div", { className: "row" }, copy)), err, el("div", { className: "row" }, cancel), ); void (async () => { while (mine === screen) { await sleep(2000); if (mine !== screen) return; try { const p = await invoke("join_poll"); if (p.code) { codeBox.className = "code"; codeBox.replaceChildren(el("div", { className: "muted", textContent: "Your phone should show:" }), el("div", { className: "digits", textContent: p.code })); } if (p.setup) { if (mine === screen) finishSetup(body, p.setup); return; } } catch (e) { err.textContent = errText(e); return; } } })(); } function kitPanel(body: HTMLElement) { const file = el("input", { type: "file", accept: ".png,.jpg,.jpeg,.pdf,image/png,image/jpeg,application/pdf" }); const err = el("div", { className: "err" }); const open = el("button", { textContent: "Open Emergency Kit…", className: "primary", onclick: () => file.click() }); file.hidden = true; file.onchange = async () => { const f = file.files?.[0]; if (!f) return; err.textContent = ""; open.disabled = true; open.textContent = "Reading…"; try { const bytes = new Uint8Array(await f.arrayBuffer()); const view = await invoke("setup_from_file", bytes); finishSetup(body, view); } catch (e) { err.textContent = errText(e); } finally { open.disabled = false; open.textContent = "Open Emergency Kit…"; file.value = ""; } }; body.append( el("p", { className: "muted", textContent: "Choose your Emergency Kit PDF, or a photo or screenshot of its QR code (PNG or JPEG). Another device will need to approve this computer." }), file, open, err, ); } function pastePanel(body: HTMLElement) { const text = el("textarea", { rows: 4, className: "mono small", placeholder: "kestrove://setup/v1?…", spellcheck: false }); const err = el("div", { className: "err" }); const next = el("button", { textContent: "Continue", className: "primary" }); next.onclick = async () => { err.textContent = ""; try { const view = await invoke("setup_from_text", { text: text.value }); text.value = ""; finishSetup(body, view); } catch (e) { err.textContent = errText(e); } }; body.append(el("p", { className: "muted", textContent: "Paste the setup code from your Emergency Kit or from “Add a device” on another device." }), text, next, err); text.focus(); } /// The last step of every way in: only the master password. function finishSetup(body: HTMLElement, view: SetupView) { const pw = el("input", { type: "password", placeholder: "Master password", autocomplete: "current-password" }); const err = el("div", { className: "err" }); const btn = el("button", { textContent: "Sign in", className: "primary" }); const waiting = el("div"); const submit = async () => { btn.disabled = true; err.textContent = ""; btn.textContent = "Signing in…"; try { const r = await invoke<"ok" | "pending_approval">("finish_setup", { masterPassword: pw.value }); if (r === "ok") { pw.value = ""; await render(); return; } const status = await invoke("sync_status"); waiting.replaceChildren(el("div", { className: "notice" }, el("p", { textContent: "Almost done: approve this computer on a device that is already signed in (Settings → Devices). Check that it shows this fingerprint:" }), el("p", { className: "mono", textContent: groups(status.device_fingerprint ?? "") }), el("p", { className: "muted", textContent: "Then come back and choose “Check again”." }))); btn.textContent = "Check again"; } catch (e) { err.textContent = errText(e); btn.textContent = "Sign in"; } btn.disabled = false; }; btn.onclick = submit; pw.onkeydown = (e) => e.key === "Enter" && void submit(); body.replaceChildren( el("p", {}, "Signing in to ", el("strong", { textContent: view.server_host }), "."), el("p", { className: "muted", textContent: view.preapproved ? "This computer is pre-approved." : "Another device will need to approve this computer." }), pw, btn, waiting, err, el("button", { textContent: "Start over", className: "link", onclick: async () => { await invoke("clear_setup"); await render(); } }), ); pw.focus(); } function createPanel(body: HTMLElement, status: SyncStatus) { const url = serverInput(status); const code = el("input", { placeholder: "Setup code (KV_SETUP_CODE in the server's .env)", spellcheck: false }); const pins = el("input", { placeholder: "SPKI pins, base64, comma-separated (optional)", className: "mono small", spellcheck: false }); const pw = el("input", { type: "password", placeholder: "Master password (10+ characters)", autocomplete: "new-password" }); const pw2 = el("input", { type: "password", placeholder: "Repeat master password", autocomplete: "new-password" }); const err = el("div", { className: "err" }); const btn = el("button", { textContent: "Create account", className: "primary" }); btn.onclick = async () => { err.textContent = ""; if (pw.value !== pw2.value) { err.textContent = "The passwords don't match."; return; } btn.disabled = true; btn.textContent = "Creating…"; try { const secretKey = await invoke("create_account", { serverUrl: url.value, pins: pins.value, setupCode: code.value, masterPassword: pw.value }); pw.value = pw2.value = ""; await showSecretKey(secretKey); } catch (e) { err.textContent = errText(e); btn.disabled = false; btn.textContent = "Create account"; // A vault created here whose server registration failed: continue to the vault (Settings → Sync retries). if ((await invoke("state")).has_account) { body.append(el("button", { textContent: "Continue to the vault", onclick: render })); } } }; body.append( el("p", { className: "muted", textContent: "For the first device on a new server. You'll get a Secret Key: keep it with your Emergency Kit." }), el("label", { className: "grid" }, "Server", url), el("label", { className: "grid" }, "Setup code", code), el("details", {}, el("summary", { textContent: "Certificate pins (advanced)" }), pins), pw, pw2, btn, err, ); } async function showSecretKey(secretKey: string) { const kit = await invoke("kit_qr").catch(() => null); const card = el("div", { className: "card wide" }, el("h2", { textContent: "Your Secret Key" }), el("p", { className: "mono big", textContent: secretKey }), el("p", { className: "muted", textContent: "Write it down, or print this screen, and keep it with your Emergency Kit. Every new device needs it with your master password. This computer remembers it." })); if (kit) { card.append( el("p", { textContent: "Setup code for your Emergency Kit (server, account and Secret Key; keep it as safe as the Secret Key):" }), el("img", { src: kit, alt: "Emergency Kit setup code", className: "qr" })); } card.append(el("button", { textContent: "Continue", className: "primary", onclick: render })); app.replaceChildren(el("div", { className: "center" }, card)); } // ------------------------------------------------------------------ unlock function renderUnlock(state: AppState) { const pw = el("input", { type: "password", placeholder: "Master password" }); const sk = el("input", { placeholder: "Secret Key (K1-…)", className: "mono" }); const err = el("div", { className: "err" }); const btn = el("button", { textContent: "Unlock", className: "primary" }); const submit = async () => { btn.disabled = true; try { await invoke("unlock", { masterPassword: pw.value, secretKey: sk.value || null }); pw.value = ""; sk.value = ""; await render(); } catch (e) { err.textContent = errText(e); btn.disabled = false; } }; btn.onclick = submit; pw.onkeydown = (e) => e.key === "Enter" && void submit(); const card = el("div", { className: "card" }, el("h2", { textContent: "Kestrove is locked" }), pw); if (!state.remembers_secret_key) card.append(sk); card.append(btn, err); app.append(el("div", { className: "center" }, card)); pw.focus(); } // ------------------------------------------------------------------ vault let refreshList: (() => Promise) | null = null; let syncBox: HTMLElement | null = null; async function renderVault(state: AppState) { const detail = el("section"); const list = el("ul"); const fill = async () => { const items = await invoke("list_items"); const selected = list.querySelector("li.sel")?.getAttribute("data-id"); list.replaceChildren(); for (const it of items) { const li = el("li", {}, el("div", { textContent: it.title }), el("div", { className: "muted", textContent: it.username ?? "" })); li.dataset.id = it.id; if (it.id === selected) li.classList.add("sel"); li.onclick = async () => { list.querySelectorAll("li").forEach((n) => n.classList.remove("sel")); li.classList.add("sel"); await renderDetail(detail, it.id); }; list.append(li); } if (!items.length) list.append(el("li", { className: "muted", textContent: "No items yet." })); }; await fill(); refreshList = fill; const add = el("button", { textContent: "+ New login", onclick: () => renderEditor(detail) }); const settings = el("button", { textContent: "Settings", onclick: () => renderSettings(detail, state) }); const lock = el("button", { textContent: "Lock", onclick: async () => { await invoke("lock"); await render(); } }); syncBox = el("div", { className: "sync" }); const aside = el("aside", {}, el("div", { className: "toolbar" }, add, settings, lock), list, syncBox); if (state.ipc_error) aside.insertBefore(el("p", { className: "err pad", textContent: state.ipc_error }), list); app.append(aside, detail); const status = await invoke("sync_status"); lastChanges = status.changes; showSync(status, detail, state); } /// Sync footer and banners in the sidebar; updated on every `sync-status` event. function showSync(s: SyncStatus, detail?: HTMLElement, state?: AppState) { if (!syncBox?.isConnected) return; const openSettings = () => { const d = detail ?? (document.querySelector("main section") as HTMLElement | null); if (d && state) void renderSettings(d, state); else if (d) void invoke("state").then((st) => renderSettings(d, st)); }; const parts: Node[] = []; if (s.integrity_alarm) { parts.push(el("div", { className: "alarm" }, el("strong", { textContent: "Sync stopped: integrity alarm" }), el("p", { textContent: s.integrity_alarm }), el("p", { textContent: "The server (or someone controlling it) sent data that doesn't match what your devices signed. Don't trust this server until you've checked it." }))); } if (s.pending_devices > 0) { parts.push(el("div", { className: "notice" }, `${s.pending_devices} device${s.pending_devices > 1 ? "s" : ""} waiting for approval. `, el("button", { textContent: "Review", className: "link", onclick: openSettings }))); } if (s.pending_approval) { parts.push(el("div", { className: "notice", textContent: `This computer waits for approval on another device. Fingerprint: ${groups(s.device_fingerprint ?? "")}` })); } if (s.restored_items > 0) { parts.push(el("div", { className: "notice", textContent: `${s.restored_items} item${s.restored_items > 1 ? "s" : ""} deleted on another device came back because they were edited here.` })); } let line: string; if (!s.server_url) line = "Not connected to a sync server"; else if (s.syncing) line = "Syncing…"; else if (s.last_error) line = s.last_error; else if (s.last_sync_at) line = `Synced ${ago(s.last_sync_at)}`; else line = "Not synced yet"; const now = el("button", { textContent: "Sync now", disabled: s.syncing || !s.server_url, onclick: async () => { now.disabled = true; await invoke("sync_now").catch(() => undefined); }, }); parts.push(el("div", { className: "row between" }, el("span", { className: s.last_error && !s.syncing ? "err small" : "muted small", textContent: line, title: s.server_url ?? "" }), s.server_url ? now : el("button", { textContent: "Set up", onclick: openSettings }))); syncBox.replaceChildren(...parts); } async function renderDetail(target: HTMLElement, id: string) { const item = await invoke("get_item", { id }); const pwSpan = el("span", { className: "mono", textContent: "••••••••" }); const rows: Node[] = [ el("h2", { textContent: item.title }), el("p", {}, "Username: ", el("span", { className: "mono", textContent: item.username ?? "" }), " ", el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id, field: "username" }) })), el("p", {}, "Password: ", pwSpan, " ", el("button", { textContent: "Reveal", onclick: () => (pwSpan.textContent = item.password ?? "") }), " ", el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id, field: "password" }) })), ]; if (item.totp) { const code = el("span", { className: "mono" }); const tick = async () => { if (!code.isConnected) return; const t = await invoke<{ code: string; seconds_remaining: number } | null>("totp", { id }).catch(() => null); code.textContent = t ? `${t.code} (${t.seconds_remaining}s)` : "—"; setTimeout(tick, 1000); }; rows.push(el("p", {}, "One-time code: ", code, " ", el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id, field: "totp" }) }))); queueMicrotask(tick); } rows.push( el("p", { className: "muted", textContent: item.urls.join(", ") }), el("button", { textContent: "Delete", className: "danger", onclick: async () => { if (confirm(`Delete “${item.title}”?`)) { await invoke("delete_item", { id }); await render(); } }, }), ); target.replaceChildren(...rows); } function renderEditor(target: HTMLElement) { const title = el("input", { placeholder: "Title" }); const user = el("input", { placeholder: "Username" }); const pass = el("input", { placeholder: "Password", className: "mono" }); const url = el("input", { placeholder: "https://example.com" }); const totp = el("input", { placeholder: "TOTP secret or otpauth:// URI (optional)", className: "mono" }); const gen = el("button", { textContent: "Generate", onclick: async () => (pass.value = await invoke("generate_password", { length: 24 })) }); const err = el("div", { className: "err" }); const save = el("button", { textContent: "Save", className: "primary" }); save.onclick = async () => { try { await invoke("save_item", { item: { title: title.value, username: user.value || null, password: pass.value || null, urls: url.value ? [url.value] : [], totp: totp.value || null }, }); await render(); } catch (e) { err.textContent = errText(e); } }; target.replaceChildren(el("div", { className: "card" }, el("h2", { textContent: "New login" }), title, user, el("div", { className: "row" }, pass, gen), url, totp, save, err)); } // ------------------------------------------------------------------ settings async function renderSettings(target: HTMLElement, state: AppState) { const pairings = await invoke("list_pairings"); const idle = el("select", {}, ...[1, 5, 10, 15, 30, 60, 120].map((m) => el("option", { value: String(m), textContent: `${m} min`, selected: m === state.idle_minutes }))); idle.onchange = () => invoke("set_idle_minutes", { minutes: Number(idle.value) }); const list = el("ul", { className: "plain" }); for (const p of pairings) { list.append( el("li", {}, el("div", { textContent: `${p.name}${p.caller ? ` · ${p.caller}` : ""}` }), el("div", { className: "muted mono", textContent: p.words.join(" ") }), el("div", { className: "muted", textContent: `Paired ${new Date(p.created_at * 1000).toLocaleString()}` }), el("button", { textContent: "Unpair", onclick: async () => { await invoke("revoke_pairing", { id: p.id }); await renderSettings(target, state); }, })), ); } if (!pairings.length) list.append(el("li", { className: "muted", textContent: "No browsers paired. Open the Kestrove extension in your browser and choose “Pair”." })); const syncSection = el("div"); const devicesSection = el("div"); target.replaceChildren( el("h2", { textContent: "Settings" }), el("label", { className: "row" }, "Lock after idle for ", idle), el("p", { className: "muted", textContent: "Kestrove also locks on screen lock and sleep." }), el("h3", { textContent: "Sync" }), syncSection, el("h3", { textContent: "Devices" }), devicesSection, el("h3", { textContent: "Emergency Kit" }), kitSection(), el("h3", { textContent: "Paired browsers" }), list, ); const status = await invoke("sync_status"); renderSyncSettings(syncSection, status, () => renderSettings(target, state)); if (status.server_url && status.logged_in) await renderDevices(devicesSection); else devicesSection.append(el("p", { className: "muted", textContent: "Connect to a sync server first." })); } function renderSyncSettings(box: HTMLElement, s: SyncStatus, reload: () => Promise) { const facts = el("dl", { className: "facts" }, el("dt", { textContent: "Server" }), el("dd", { className: "mono", textContent: s.server_url ?? "—" }), el("dt", { textContent: "Pins" }), el("dd", { className: "mono small", textContent: s.pins.length ? s.pins.join(", ") : "none (system certificates)" }), el("dt", { textContent: "Last sync" }), el("dd", { textContent: s.last_sync_at ? ago(s.last_sync_at) : "never" }), el("dt", { textContent: "This device" }), el("dd", { className: "mono", textContent: groups(s.device_fingerprint ?? "—") }), el("dt", { textContent: "Device key" }), el("dd", { textContent: s.key_storage ?? "unavailable" })); box.replaceChildren(facts); if (s.key_warning) box.append(el("p", { className: "warn", textContent: s.key_warning })); if (s.last_error) box.append(el("p", { className: "err", textContent: s.last_error })); if (s.server_url && s.logged_in) return; // A vault from before sync (or whose registration failed): connect it. const url = el("input", { type: "url", placeholder: "https://kestrove.example.com", value: s.server_url ?? "" }); const pins = el("input", { placeholder: "SPKI pins (optional)", className: "mono small", value: s.pins.join(", ") }); const code = el("input", { placeholder: "Setup code (only for a new server)" }); const err = el("div", { className: "err" }); const btn = el("button", { textContent: "Connect", className: "primary" }); btn.onclick = async () => { btn.disabled = true; err.textContent = ""; try { await invoke("configure_server", { serverUrl: url.value, pins: pins.value }); if (await invoke("server_setup_open")) { if (!code.value.trim()) throw new Error("This server has no account yet: enter its setup code to register this vault."); await invoke("register_server", { setupCode: code.value }); } else { await invoke("sync_now"); } await reload(); } catch (e) { err.textContent = errText(e); btn.disabled = false; } }; box.append(el("div", { className: "card" }, url, pins, code, btn, err)); } async function renderDevices(box: HTMLElement) { const err = el("div", { className: "err" }); const list = el("ul", { className: "plain" }); const reload = () => renderDevices(box); let devices: DeviceView[] = []; try { devices = await invoke("list_devices"); } catch (e) { err.textContent = errText(e); } for (const d of devices.filter((d) => !d.revoked)) { const badges = [d.current ? "this device" : "", d.approved ? "" : "waiting for approval"].filter(Boolean).join(" · "); const li = el("li", {}, el("div", {}, el("strong", { textContent: d.name }), ` ${d.platform}`, badges ? el("span", { className: d.approved ? "muted" : "warn", textContent: ` · ${badges}` }) : ""), el("div", { className: "muted mono small", textContent: groups(d.key_fingerprint) }), el("div", { className: "muted small", textContent: `Added ${new Date(d.created_at).toLocaleString()}` })); const actions = el("div", { className: "row" }); if (!d.approved) { actions.append(el("button", { textContent: "Approve…", className: "primary", onclick: () => confirmModal( `Approve “${d.name}”?`, [el("p", { textContent: "Only approve it if the new device shows exactly this fingerprint:" }), el("p", { className: "mono big", textContent: groups(d.key_fingerprint) })], "The fingerprints match: approve", async () => { await invoke("approve_device", { deviceId: d.device_id }); await reload(); }), })); } if (!d.current) { actions.append(el("button", { textContent: d.approved ? "Remove" : "Reject", className: "danger", onclick: () => confirmModal(`Remove “${d.name}”?`, [el("p", { textContent: "It will be signed out and can't sync again. Its local copy stays on it." })], "Remove device", async () => { await invoke("revoke_device", { deviceId: d.device_id }); await reload(); }), })); } li.append(actions); list.append(li); } box.replaceChildren( el("div", { className: "row" }, el("button", { textContent: "Add a phone or tablet…", onclick: addDeviceModal }), el("button", { textContent: "Approve a computer…", onclick: approveComputerModal })), list, err); } function kitSection(): HTMLElement { const box = el("div"); const show = el("button", { textContent: "Show Emergency Kit setup code", onclick: async () => { try { const qr = await invoke("kit_qr"); box.replaceChildren( el("p", { className: "warn", textContent: "This code contains your Secret Key. Keep it as safe as your Emergency Kit." }), el("img", { src: qr, alt: "Emergency Kit setup code", className: "qr" }), el("button", { textContent: "Hide", onclick: () => box.replaceChildren(show) })); } catch (e) { box.append(el("p", { className: "err", textContent: errText(e) })); } }, }); box.append(show); return box; } // ------------------------------------------------------------------ dialogs function closeModal() { modal.replaceChildren(); modal.hidden = true; } function openModal(...children: Node[]) { modal.replaceChildren(el("div", { className: "prompt" }, ...children)); modal.hidden = false; } function confirmModal(title: string, body: Node[], yes: string, action: () => Promise) { const err = el("div", { className: "err" }); const ok = el("button", { textContent: yes, className: "primary" }); ok.onclick = async () => { ok.disabled = true; try { await action(); closeModal(); } catch (e) { err.textContent = errText(e); ok.disabled = false; } }; openModal(el("h3", { textContent: title }), ...body, el("div", { className: "row" }, ok, el("button", { textContent: "Cancel", onclick: closeModal })), err); } /// "Add a device": a single-use, 10-minute QR. Cancel or Done revokes the ticket if it wasn't used. async function addDeviceModal() { let view: AddDeviceView; try { view = await invoke("add_device"); } catch (e) { openModal(el("h3", { textContent: "Add a device" }), el("p", { className: "err", textContent: errText(e) }), el("button", { textContent: "Close", onclick: closeModal })); return; } const done = async () => { closeModal(); await invoke("cancel_add_device"); }; openModal( el("h3", { textContent: "Add a phone or tablet" }), el("p", { textContent: "In Kestrove on the new device, choose “Scan setup code” and scan this. Then it only asks for your master password." }), el("img", { src: view.qr_svg, alt: "Setup code for a new device", className: "qr" }), el("p", { className: "warn small", textContent: "It contains your Secret Key and works once. Don't show it to anyone else." }), el("div", { className: "row between" }, countdown(view.expires_at, () => { if (!modal.hidden) void done(); }), el("button", { textContent: "Done", className: "primary", onclick: done })), ); } /// Desktops usually have no camera: approve a new computer from its pasted join code. function approveComputerModal() { const text = el("textarea", { rows: 4, className: "mono small", placeholder: "kestrove://join/v1?…", spellcheck: false }); const err = el("div", { className: "err" }); const next = el("button", { textContent: "Continue", className: "primary" }); const cancel = async () => { await invoke("join_cancel"); closeModal(); }; next.onclick = async () => { next.disabled = true; err.textContent = ""; try { const c = await invoke("approve_join", { joinCode: text.value }); const approve = el("button", { textContent: "The codes match: approve", className: "primary" }); const err2 = el("div", { className: "err" }); approve.onclick = async () => { approve.disabled = true; try { await invoke("send_join"); openModal(el("h3", { textContent: "Approved" }), el("p", { textContent: `${c.device_name} will finish setting up and ask for your master password.` }), el("button", { textContent: "Close", className: "primary", onclick: closeModal })); } catch (e) { err2.textContent = errText(e); approve.disabled = false; } }; openModal( el("h3", { textContent: `Add “${c.device_name}” (${c.platform})?` }), el("p", { textContent: "Check that the new computer shows exactly:" }), el("div", { className: "digits", textContent: c.code }), el("p", { className: "muted small", textContent: `Device key ${groups(c.key_fingerprint)}` }), el("div", { className: "row" }, approve, el("button", { textContent: "Cancel", onclick: cancel })), err2, ); } catch (e) { err.textContent = errText(e); next.disabled = false; } }; openModal( el("h3", { textContent: "Approve a computer" }), el("p", { textContent: "On the new computer choose “Use my phone”, open “No phone? Approve from another computer” and copy the join code. Paste it here:" }), text, el("div", { className: "row" }, next, el("button", { textContent: "Cancel", onclick: cancel })), err, ); text.focus(); } function copyText(area: HTMLTextAreaElement) { area.select(); if (navigator.clipboard) void navigator.clipboard.writeText(area.value).catch(() => document.execCommand("copy")); else document.execCommand("copy"); } // ------------------------------------------------------------------ browser prompts (pairing, save) async function renderPrompts(state: AppState) { overlay.replaceChildren(); const pairing = await invoke("pending_pairing"); if (pairing) overlay.append(pairingCard(pairing, state)); if (state.has_account && !state.locked) { for (const s of await invoke("pending_saves")) overlay.append(saveCard(s)); } else if (state.has_account && (await invoke("pending_saves")).length) { overlay.append(el("div", { className: "prompt" }, el("p", { textContent: "Unlock Kestrove to review logins your browser asked to save." }))); } overlay.hidden = !overlay.childElementCount; } function pairingCard(p: PairingView, state: AppState): HTMLElement { const err = el("div", { className: "err" }); const words = el("div", { className: "words" }, ...p.words.map((w) => el("span", { textContent: w }))); const accept = el("button", { textContent: "The words match: pair", className: "primary" }); const reject = el("button", { textContent: "Don't pair" }); const resolve = async (yes: boolean) => { try { await invoke("resolve_pairing", { id: p.id, accept: yes }); await render(); } catch (e) { err.textContent = errText(e); } }; accept.onclick = () => resolve(true); reject.onclick = () => resolve(false); accept.disabled = state.locked; return el("div", { className: "prompt" }, el("h3", { textContent: "Pair a browser?" }), el("p", { textContent: `${p.name}${p.caller ? ` (${p.caller})` : ""} wants to use Kestrove. Check that the extension shows exactly these words:` }), words, state.locked ? el("p", { className: "muted", textContent: "Unlock Kestrove first, then confirm." }) : "", el("div", { className: "row" }, accept, reject), err); } function saveCard(s: SaveView): HTMLElement { const err = el("div", { className: "err" }); const yes = el("button", { textContent: s.update ? "Update password" : "Save", className: "primary" }); const no = el("button", { textContent: "Not now" }); const resolve = async (accept: boolean) => { try { await invoke("resolve_save", { id: s.id, accept }); await render(); } catch (e) { err.textContent = errText(e); } }; yes.onclick = () => resolve(true); no.onclick = () => resolve(false); return el("div", { className: "prompt" }, el("h3", { textContent: s.update ? "Update saved password?" : "Save this login?" }), el("p", {}, el("span", { className: "mono", textContent: s.username || "(no username)" }), " on ", el("span", { className: "mono", textContent: s.origin })), el("div", { className: "row" }, yes, no), err); } // ------------------------------------------------------------------ wiring void listen("pairing-request", () => render()); void listen("save-request", () => render()); void listen("locked", () => { closeModal(); void render(); }); let alarmShown = false; void listen("sync-status", (e) => { const s = e.payload; showSync(s); if (lastChanges !== -1 && s.changes !== lastChanges && refreshList && syncBox?.isConnected) void refreshList(); lastChanges = s.changes; if (s.integrity_alarm && !alarmShown) { alarmShown = true; openModal(el("h3", { textContent: "Sync stopped: integrity alarm" }), el("p", { textContent: s.integrity_alarm }), el("p", { textContent: "Your local data is unchanged and stays usable. Don't trust the server until you've checked it." }), el("button", { textContent: "OK", className: "primary", onclick: closeModal })); } }); // Keep "Synced 3 min ago" fresh. setInterval(() => { if (syncBox?.isConnected) void invoke("sync_status").then((s) => showSync(s)); }, 30_000); // Activity for the idle timer (Linux has no system idle time without extra deps). let lastTouch = 0; for (const ev of ["keydown", "mousedown", "mousemove"]) { document.addEventListener(ev, () => { const now = Date.now(); if (now - lastTouch > 30_000) { lastTouch = now; void invoke("touch"); } }, { passive: true }); } void render();