- Device key (DeviceSigner): Windows TPM via CNG with a DPAPI-sealed software fallback, Linux Secret Service with a 0600 file fallback (warned in Settings), macOS keychain for dev runs. The store used is recorded; no silent switching. - SyncService (Tauri-free): server URL and pins, create account with the server's setup code, sync after unlock/save/browser save, every 15 min while unlocked and on demand, status events, integrity alarm, device list with fingerprint approval and revoke. - Onboarding: "Use my phone" (join_start/poll, QR rendered locally, 6-digit code), Emergency Kit from PDF/PNG/JPEG (hayro + rqrr), paste setup code, new account. Then only the master password. - From a signed-in desktop: "Add a phone or tablet" QR with countdown (cancel, lock and quit revoke the ticket) and "Approve a computer" via pasted join code. - KESTROVE_DATA_DIR / KESTROVE_DEVICE_NAME for a second instance; ignored live test drives three instances against a real server. - deny: ignore three unmaintained notices for zbus 5 sub-crates (Linux keyring). Co-Authored-By: Claude Opus 5.5 <[email protected]>
846 lines
38 KiB
TypeScript
846 lines
38 KiB
TypeScript
// 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<K extends keyof HTMLElementTagNameMap>(tag: K, props: Partial<HTMLElementTagNameMap[K]> & Record<string, unknown> = {}, ...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<AppState>("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<SyncStatus>("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<JoinView>("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<JoinPollView>("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<SetupView>("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<SetupView>("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<SyncStatus>("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<string>("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<AppState>("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<string>("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<void>) | 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<ItemSummary[]>("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<SyncStatus>("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<AppState>("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<ItemDto>("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<string>("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<Pairing[]>("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<SyncStatus>("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<void>) {
|
|
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<boolean>("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<DeviceView[]>("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<string>("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<void>) {
|
|
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<AddDeviceView>("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<JoinConfirmView>("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<PairingView | null>("pending_pairing");
|
|
if (pairing) overlay.append(pairingCard(pairing, state));
|
|
if (state.has_account && !state.locked) {
|
|
for (const s of await invoke<SaveView[]>("pending_saves")) overlay.append(saveCard(s));
|
|
} else if (state.has_account && (await invoke<SaveView[]>("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<SyncStatus>("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<SyncStatus>("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();
|