Create account takes an email and an invite or setup code (none with open sign-up), joins servers that already have accounts, and Settings shows the account email (admins manage people from the web vault). Co-Authored-By: Claude Opus 5.5 <[email protected]>
1042 lines
44 KiB
TypeScript
1042 lines
44 KiB
TypeScript
// Desktop UI. All vault logic runs in Rust (src-tauri → kv-ffi); this layer only renders, with the
|
|
// Kestrove components in ./ui (the same ones the browser extension uses).
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
import {
|
|
banner, button, codeText, copyButton, dots, field, h, icon, iconButton, input, integrityBanner, itemRow, lockup,
|
|
pill, ring, secret, tile, type IconName,
|
|
} from "./ui/kv";
|
|
|
|
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")!;
|
|
const isMac = navigator.userAgent.includes("Macintosh");
|
|
document.documentElement.classList.toggle("mac", isMac);
|
|
|
|
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();
|
|
const errBox = () => h("div", "kv-error");
|
|
const setErr = (box: HTMLElement, e: unknown) => box.replaceChildren(icon("octagon", "sm"), errText(e));
|
|
const p = (cls: string, ...children: (Node | string)[]) => h("p", cls, ...children);
|
|
const stack = (cls = "", ...children: (Node | string | null | false)[]) => h("div", `stack ${cls}`.trim(), ...children);
|
|
|
|
function countdown(expiresAt: number, onExpire: () => void): HTMLElement {
|
|
const span = h("span", "kv-caption kv-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();
|
|
}
|
|
|
|
// ------------------------------------------------------------------ theme (Light, Dark, System)
|
|
|
|
type Theme = "system" | "light" | "dark";
|
|
|
|
function storedTheme(): Theme {
|
|
try {
|
|
const t = localStorage.getItem("theme");
|
|
return t === "light" || t === "dark" ? t : "system";
|
|
} catch {
|
|
return "system";
|
|
}
|
|
}
|
|
|
|
function applyTheme(t: Theme) {
|
|
if (t === "system") delete document.documentElement.dataset.theme;
|
|
else document.documentElement.dataset.theme = t;
|
|
// The native title bar and window controls follow too.
|
|
void getCurrentWindow().setTheme(t === "system" ? null : t).catch(() => undefined);
|
|
paintTitleBar();
|
|
}
|
|
|
|
/// Windows and Linux keep the native title bar (Tauri overlays it only on macOS); on Windows 11 its caption
|
|
/// takes the theme's `bg` and `text`, so it and the header below read as one custom title bar.
|
|
function paintTitleBar() {
|
|
if (isMac) return;
|
|
const css = getComputedStyle(document.documentElement);
|
|
const bg = css.getPropertyValue("--bg").trim();
|
|
const text = css.getPropertyValue("--text").trim();
|
|
void invoke("set_title_bar_colors", { bg, text }).catch(() => undefined);
|
|
}
|
|
|
|
// "System" follows the OS setting while the app runs.
|
|
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", paintTitleBar);
|
|
|
|
function setTheme(t: Theme) {
|
|
try {
|
|
localStorage.setItem("theme", t);
|
|
} catch {
|
|
// Not persisted; still applied for this run.
|
|
}
|
|
applyTheme(t);
|
|
}
|
|
|
|
applyTheme(storedTheme());
|
|
|
|
// ------------------------------------------------------------------ chrome
|
|
|
|
/// The custom title bar in `bg`: native window controls (macOS traffic lights sit over its left edge).
|
|
function titleBar(...trailing: Node[]): HTMLElement {
|
|
const bar = h("header", "titlebar", lockup(20, "kv-word sm"), h("span", "grow"), ...trailing);
|
|
bar.dataset.tauriDragRegion = "";
|
|
bar.querySelectorAll("span").forEach((s) => (s.dataset.tauriDragRegion = ""));
|
|
return bar;
|
|
}
|
|
|
|
/// Centered single-card screens: onboarding, unlock, the Secret Key.
|
|
function centered(card: HTMLElement) {
|
|
app.className = "auth";
|
|
app.replaceChildren(titleBar(), h("div", "center", card));
|
|
}
|
|
|
|
/// 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 = stack();
|
|
const tabs: [Tab, string][] = [["phone", "Use my phone"], ["kit", "Emergency Kit"], ["paste", "Paste setup code"], ["create", "New account"]];
|
|
const nav = h("div", "seg", ...tabs.map(([t, label]) => {
|
|
const b = button(label, "quiet", async () => {
|
|
await invoke("join_cancel");
|
|
app.replaceChildren();
|
|
screen++;
|
|
await renderOnboarding(t);
|
|
});
|
|
b.setAttribute("aria-pressed", String(t === tab));
|
|
return b;
|
|
}));
|
|
nav.setAttribute("role", "group");
|
|
nav.setAttribute("aria-label", "How to set up");
|
|
const card = h("div", "kv-card auth-card wide", h("h1", "kv-title-1", "Set up Kestrove on this computer"), nav, body);
|
|
if (status.key_warning) card.append(banner("warn", "Your device key is stored in a file", status.key_warning));
|
|
centered(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 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 = errBox();
|
|
const start = button("Show QR code", "primary");
|
|
body.append(
|
|
p("kv-muted", "Your phone sends this computer everything it needs. You'll only type your master password."),
|
|
field("Your Kestrove server", url, "The address you open in a browser."),
|
|
h("div", "actions", start), err,
|
|
);
|
|
start.onclick = async () => {
|
|
start.disabled = true;
|
|
err.replaceChildren();
|
|
try {
|
|
const join = await invoke<JoinView>("join_start", { serverUrl: url.value });
|
|
showJoin(body, join);
|
|
} catch (e) {
|
|
setErr(err, e);
|
|
start.disabled = false;
|
|
}
|
|
};
|
|
// Block body on purpose: a DOM `on…` handler that returns `false` cancels the event, which would block typing.
|
|
url.onkeydown = (e) => {
|
|
if (e.key === "Enter") start.click();
|
|
};
|
|
if (!url.value) url.focus();
|
|
}
|
|
|
|
function qrCard(src: string, alt: string): HTMLElement {
|
|
const img = h("img");
|
|
img.src = src;
|
|
img.alt = alt;
|
|
return h("div", "kv-qr", img);
|
|
}
|
|
|
|
function steps(...items: (string | Node)[]): HTMLElement {
|
|
return h("ol", "steps kv-small kv-muted", ...items.map((i) => h("li", "", i)));
|
|
}
|
|
|
|
function showJoin(body: HTMLElement, join: JoinView) {
|
|
const mine = screen;
|
|
const codeBox = h("div", "confirm", h("span", "kv-hint", "Waiting for your phone…"));
|
|
const err = errBox();
|
|
const text = h("textarea", "kv-field mono kv-small");
|
|
text.readOnly = true;
|
|
text.rows = 3;
|
|
text.value = join.qr_text;
|
|
const cancel = button("Cancel", "quiet", async () => {
|
|
await invoke("join_cancel");
|
|
await render();
|
|
});
|
|
body.replaceChildren(h("div", "qr-sheet",
|
|
steps("Open Kestrove on your phone.", h("span", "", "Go to ", h("b", "", "Settings → Devices"), "."), h("span", "", "Choose ", h("b", "", "Scan a computer's code"), ".")),
|
|
qrCard(join.qr_svg, "QR code for your phone"),
|
|
countdown(join.expires_at, () => {
|
|
if (mine === screen) setErr(err, "The code expired. Cancel and start again.");
|
|
}),
|
|
codeBox,
|
|
cancel,
|
|
err),
|
|
h("details", "",
|
|
h("summary", "kv-small", "No phone? Approve from another computer"),
|
|
p("kv-small kv-muted", "On a computer that is already signed in, open Settings → Devices → Approve a computer, and paste this join code:"),
|
|
text, h("div", "actions", button("Copy join code", "secondary", () => copyText(text)))),
|
|
);
|
|
void (async () => {
|
|
while (mine === screen) {
|
|
await sleep(2000);
|
|
if (mine !== screen) return;
|
|
try {
|
|
const poll = await invoke<JoinPollView>("join_poll");
|
|
if (poll.code) codeBox.replaceChildren(h("span", "kv-hint", "Your phone should show:"), codeText(poll.code, true));
|
|
if (poll.setup) {
|
|
if (mine === screen) finishSetup(body, poll.setup);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
setErr(err, e);
|
|
return;
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
|
|
function kitPanel(body: HTMLElement) {
|
|
const file = h("input");
|
|
file.type = "file";
|
|
file.accept = ".png,.jpg,.jpeg,.pdf,image/png,image/jpeg,application/pdf";
|
|
const err = errBox();
|
|
const open = button("Open Emergency Kit…", "primary", () => file.click());
|
|
file.hidden = true;
|
|
file.onchange = async () => {
|
|
const f = file.files?.[0];
|
|
if (!f) return;
|
|
err.replaceChildren();
|
|
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) {
|
|
setErr(err, e);
|
|
} finally {
|
|
open.disabled = false;
|
|
open.textContent = "Open Emergency Kit…";
|
|
file.value = "";
|
|
}
|
|
};
|
|
body.append(
|
|
p("kv-muted", "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, h("div", "actions", open), err,
|
|
);
|
|
}
|
|
|
|
function pastePanel(body: HTMLElement) {
|
|
const text = h("textarea", "kv-field mono kv-small");
|
|
text.rows = 4;
|
|
text.placeholder = "kestrove://setup/v1?…";
|
|
text.spellcheck = false;
|
|
const err = errBox();
|
|
const next = button("Continue", "primary");
|
|
next.onclick = async () => {
|
|
err.replaceChildren();
|
|
try {
|
|
const view = await invoke<SetupView>("setup_from_text", { text: text.value });
|
|
text.value = "";
|
|
finishSetup(body, view);
|
|
} catch (e) {
|
|
setErr(err, e);
|
|
}
|
|
};
|
|
body.append(field("Setup code", text, "From your Emergency Kit, or from “Add a device” on another device."), h("div", "actions", next), err);
|
|
text.focus();
|
|
}
|
|
|
|
/// The last step of every way in: only the master password.
|
|
function finishSetup(body: HTMLElement, view: SetupView) {
|
|
const pw = input({ type: "password", autocomplete: "current-password" });
|
|
const err = errBox();
|
|
const btn = button("Sign in", "primary");
|
|
const waiting = h("div");
|
|
const submit = async () => {
|
|
btn.disabled = true;
|
|
err.replaceChildren();
|
|
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(banner("warn", "Almost done: approve this computer",
|
|
h("div", "", p("", "On a device that is already signed in, open Settings → Devices and check that it shows this fingerprint:"),
|
|
p("fingerprint", groups(status.device_fingerprint ?? "")), p("kv-muted", "Then come back and choose “Check again”."))));
|
|
btn.textContent = "Check again";
|
|
} catch (e) {
|
|
setErr(err, e);
|
|
btn.textContent = "Sign in";
|
|
}
|
|
btn.disabled = false;
|
|
};
|
|
btn.onclick = submit;
|
|
pw.onkeydown = (e) => {
|
|
if (e.key === "Enter") void submit();
|
|
};
|
|
body.replaceChildren(
|
|
p("", "Signing in to ", h("strong", "", view.server_host), "."),
|
|
p("kv-muted", view.preapproved ? "This computer is pre-approved." : "Another device will need to approve this computer."),
|
|
field("Master password", pw), h("div", "actions", btn), waiting, err,
|
|
button("Start over", "text", async () => { await invoke("clear_setup"); await render(); }),
|
|
);
|
|
pw.focus();
|
|
}
|
|
|
|
function createPanel(body: HTMLElement, status: SyncStatus) {
|
|
const url = serverInput(status);
|
|
const email = input({ type: "email", autocomplete: "email", spellcheck: false });
|
|
const code = input({ spellcheck: false });
|
|
const pins = input({ placeholder: "base64, comma-separated", spellcheck: false }, true);
|
|
const pw = input({ type: "password", autocomplete: "new-password" });
|
|
const pw2 = input({ type: "password", autocomplete: "new-password" });
|
|
const err = errBox();
|
|
const btn = button("Create account", "primary");
|
|
btn.onclick = async () => {
|
|
err.replaceChildren();
|
|
if (pw.value !== pw2.value) {
|
|
setErr(err, "The passwords don't match.");
|
|
pw2.classList.add("invalid");
|
|
return;
|
|
}
|
|
pw2.classList.remove("invalid");
|
|
btn.disabled = true;
|
|
btn.textContent = "Creating…";
|
|
try {
|
|
const secretKey = await invoke<string>("create_account", { serverUrl: url.value, pins: pins.value, setupCode: code.value, email: email.value, masterPassword: pw.value });
|
|
pw.value = pw2.value = "";
|
|
await showSecretKey(secretKey);
|
|
} catch (e) {
|
|
setErr(err, 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(button("Continue to the vault", "secondary", render));
|
|
}
|
|
}
|
|
};
|
|
body.append(
|
|
p("kv-muted", "A new account on your Kestrove server. You'll get a Secret Key: keep it with your Emergency Kit."),
|
|
field("Your Kestrove server", url),
|
|
field("Email", email, "You sign in with it on new devices."),
|
|
field("Invite or setup code", code, "The invite from your server's admin, or KV_SETUP_CODE from its .env for the first account. Empty with open sign-up."),
|
|
h("details", "", h("summary", "kv-small", "Certificate pins (advanced)"), field("SPKI pins", pins)),
|
|
field("Master password", pw, "10 or more characters."),
|
|
field("Repeat master password", pw2),
|
|
h("div", "actions", btn), err,
|
|
);
|
|
}
|
|
|
|
async function showSecretKey(secretKey: string) {
|
|
const kit = await invoke<string>("kit_qr").catch(() => null);
|
|
const card = h("div", "kv-card auth-card wide",
|
|
h("h1", "kv-title-1", "Your Secret Key"),
|
|
h("div", "secret-key", secret(secretKey)),
|
|
p("kv-muted", "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(
|
|
p("", "Setup code for your Emergency Kit (server, account and Secret Key; keep it as safe as the Secret Key):"),
|
|
h("div", "center-row", qrCard(kit, "Emergency Kit setup code")));
|
|
}
|
|
card.append(h("div", "actions", button("Continue", "primary", render)));
|
|
centered(card);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ unlock
|
|
|
|
function renderUnlock(state: AppState) {
|
|
const pw = input({ type: "password", autocomplete: "current-password" });
|
|
const sk = input({ placeholder: "K1-XXXXXX-XXXXX-…", spellcheck: false }, true);
|
|
const err = errBox();
|
|
const btn = button("Unlock", "primary", undefined, "block");
|
|
const submit = async () => {
|
|
btn.disabled = true;
|
|
err.replaceChildren();
|
|
pw.classList.remove("invalid");
|
|
try {
|
|
await invoke("unlock", { masterPassword: pw.value, secretKey: sk.value || null });
|
|
pw.value = "";
|
|
sk.value = "";
|
|
await render();
|
|
} catch (e) {
|
|
setErr(err, e);
|
|
pw.classList.add("invalid");
|
|
btn.disabled = false;
|
|
}
|
|
};
|
|
btn.onclick = submit;
|
|
pw.onkeydown = (e) => {
|
|
if (e.key === "Enter") void submit();
|
|
};
|
|
const card = h("div", "kv-card auth-card", h("div", "unlock-mark", lockup(32)), h("h1", "kv-title-1", "Unlock your vault"), field("Master password", pw));
|
|
if (!state.remembers_secret_key) card.append(field("Secret Key", sk, "It's on your Emergency Kit. Dashes are optional."));
|
|
card.append(err, btn);
|
|
centered(card);
|
|
pw.focus();
|
|
}
|
|
|
|
// ------------------------------------------------------------------ vault
|
|
|
|
let refreshList: (() => Promise<void>) | null = null;
|
|
let syncBox: HTMLElement | null = null;
|
|
let syncPill: HTMLElement | null = null;
|
|
let alarmBox: HTMLElement | null = null;
|
|
type Category = "all" | "favorites";
|
|
let category: Category = "all";
|
|
let selectedId: string | null = null;
|
|
|
|
async function renderVault(state: AppState) {
|
|
const detail = h("section", "detail");
|
|
const list = h("div", "kv-list");
|
|
list.setAttribute("role", "listbox");
|
|
list.setAttribute("aria-label", "Items");
|
|
const search = input({ type: "search", placeholder: "Search", spellcheck: false });
|
|
search.setAttribute("aria-label", "Search items");
|
|
let items: ItemSummary[] = [];
|
|
const draw = () => {
|
|
const q = search.value.trim().toLowerCase();
|
|
const shown = items.filter((it) => (category === "all" || it.favorite) && (!q || it.title.toLowerCase().includes(q) || (it.username ?? "").toLowerCase().includes(q)));
|
|
list.replaceChildren();
|
|
for (const it of shown) {
|
|
const row = itemRow(it.title, it.username ?? "", { favorite: it.favorite, selected: it.id === selectedId });
|
|
row.setAttribute("role", "option");
|
|
row.setAttribute("aria-selected", String(it.id === selectedId));
|
|
row.onclick = async () => {
|
|
selectedId = it.id;
|
|
list.querySelectorAll(".kv-item").forEach((n) => { n.classList.remove("sel"); n.setAttribute("aria-selected", "false"); });
|
|
row.classList.add("sel");
|
|
row.setAttribute("aria-selected", "true");
|
|
await renderDetail(detail, it.id);
|
|
};
|
|
list.append(row);
|
|
}
|
|
if (!shown.length) {
|
|
list.append(h("div", "empty kv-small kv-muted", items.length ? "Nothing matches." : "No items yet. Choose “New item” to add a login."));
|
|
}
|
|
};
|
|
const fill = async () => {
|
|
items = await invoke<ItemSummary[]>("list_items");
|
|
if (selectedId && !items.some((i) => i.id === selectedId)) selectedId = null;
|
|
draw();
|
|
};
|
|
search.oninput = draw;
|
|
await fill();
|
|
refreshList = fill;
|
|
|
|
const cat = (c: Category, label: string, ic: IconName) => {
|
|
const b = h("button", "rail-item", icon(ic, "sm"), h("span", "grow", label));
|
|
b.type = "button";
|
|
if (c === category) b.setAttribute("aria-current", "page");
|
|
b.onclick = () => {
|
|
category = c;
|
|
rail.querySelectorAll(".rail-item").forEach((n) => n.removeAttribute("aria-current"));
|
|
b.setAttribute("aria-current", "page");
|
|
draw();
|
|
};
|
|
return b;
|
|
};
|
|
const settings = h("button", "rail-item", icon("settings", "sm"), h("span", "grow", "Settings"));
|
|
settings.type = "button";
|
|
settings.onclick = () => {
|
|
rail.querySelectorAll(".rail-item").forEach((n) => n.removeAttribute("aria-current"));
|
|
settings.setAttribute("aria-current", "page");
|
|
void renderSettings(detail, state);
|
|
};
|
|
syncBox = h("div", "rail-foot");
|
|
const rail = h("nav", "rail", h("div", "rail-h kv-label", "Vault"), cat("all", "All items", "key"), cat("favorites", "Favourites", "star"),
|
|
h("div", "rail-h kv-label", "Manage"), settings, h("span", "grow"), syncBox);
|
|
rail.setAttribute("aria-label", "Categories");
|
|
if (state.ipc_error) rail.insertBefore(banner("bad", "Browser connection unavailable", state.ipc_error), syncBox);
|
|
|
|
const add = button("New item", "quiet", () => {
|
|
selectedId = null;
|
|
draw();
|
|
renderEditor(detail);
|
|
});
|
|
add.prepend(icon("plus", "sm"));
|
|
const listPane = h("div", "listpane", h("div", "list-head", h("div", "search", icon("search", "sm"), search), add), list);
|
|
syncPill = h("span");
|
|
const lock = iconButton("lock", "Lock", async () => { await invoke("lock"); await render(); });
|
|
alarmBox = h("div", "alarm-slot");
|
|
app.className = "shell";
|
|
app.replaceChildren(titleBar(syncPill, lock), alarmBox, h("div", "cols", rail, listPane, detail));
|
|
if (selectedId) await renderDetail(detail, selectedId);
|
|
else detail.append(emptyDetail());
|
|
const status = await invoke<SyncStatus>("sync_status");
|
|
lastChanges = status.changes;
|
|
showSync(status, detail, state);
|
|
}
|
|
|
|
function emptyDetail(): HTMLElement {
|
|
return h("div", "empty-detail", lockup(32), p("kv-muted", "Choose an item to see it here."));
|
|
}
|
|
|
|
function syncLine(s: SyncStatus): [ReturnType<typeof pill>, string] {
|
|
if (s.integrity_alarm) return [pill("bad", "Sync stopped"), "Kestrove stopped syncing this vault."];
|
|
if (!s.server_url) return [pill("off", "Not syncing"), "Not connected to your server."];
|
|
if (s.pending_approval) return [pill("wait", "Waiting for approval"), "Approve this computer on another device."];
|
|
if (s.syncing) return [pill("ok", "Syncing…"), "Syncing…"];
|
|
if (s.last_error) return [pill("off", "Offline"), s.last_error];
|
|
if (s.last_sync_at) return [pill("ok", "Synced"), `Synced ${ago(s.last_sync_at)}`];
|
|
return [pill("off", "Not synced yet"), "Not synced yet"];
|
|
}
|
|
|
|
/// Sync status: the title-bar pill, the rail footer and the banners; 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("section.detail") as HTMLElement | null);
|
|
if (d && state) void renderSettings(d, state);
|
|
else if (d) void invoke<AppState>("state").then((st) => renderSettings(d, st));
|
|
};
|
|
const [statusPill, line] = syncLine(s);
|
|
syncPill?.replaceChildren(statusPill);
|
|
alarmBox?.replaceChildren(...(s.integrity_alarm ? [integrityBanner()] : []));
|
|
const parts: Node[] = [];
|
|
if (s.pending_devices > 0) {
|
|
parts.push(banner("warn", `${s.pending_devices} device${s.pending_devices > 1 ? "s" : ""} waiting for approval`, undefined, button("Review", "text", openSettings)));
|
|
}
|
|
if (s.pending_approval) {
|
|
parts.push(banner("info", "This computer is waiting for approval", h("span", "", "Fingerprint ", h("span", "fingerprint", groups(s.device_fingerprint ?? "")))));
|
|
}
|
|
if (s.restored_items > 0) {
|
|
parts.push(banner("info", `${s.restored_items} item${s.restored_items > 1 ? "s" : ""} came back`, "They were deleted on another device but edited here."));
|
|
}
|
|
const now = iconButton("sync", "Sync now", async () => {
|
|
now.disabled = true;
|
|
await invoke("sync_now").catch(() => undefined);
|
|
});
|
|
now.disabled = s.syncing || !s.server_url;
|
|
const lineEl = h("span", `kv-caption grow ${s.last_error && !s.syncing ? "kv-error-text" : "kv-muted"}`, line);
|
|
lineEl.title = s.server_url ?? "";
|
|
parts.push(h("div", "sync-line", lineEl, s.server_url ? now : button("Set up", "text", openSettings)));
|
|
syncBox.replaceChildren(...parts);
|
|
}
|
|
|
|
let remask: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
function fieldRow(label: string, value: Node, ...actions: Node[]): HTMLElement {
|
|
return h("div", "kv-frow", h("div", "val", h("span", "kv-label", label), value), ...actions);
|
|
}
|
|
|
|
async function renderDetail(target: HTMLElement, id: string) {
|
|
const item = await invoke<ItemDto>("get_item", { id });
|
|
const rows: HTMLElement[] = [];
|
|
if (item.username) {
|
|
rows.push(fieldRow("Username", h("span", "", item.username), copyButton("Copy username", () => invoke("copy_secret", { id, field: "username" }))));
|
|
}
|
|
if (item.password) {
|
|
const value = h("span", "", dots("Password"));
|
|
let shown = false;
|
|
const reveal = iconButton("eye", "Reveal password");
|
|
const mask = () => {
|
|
shown = false;
|
|
clearTimeout(remask);
|
|
value.replaceChildren(dots("Password"));
|
|
reveal.replaceChildren(icon("eye"));
|
|
reveal.setAttribute("aria-label", "Reveal password");
|
|
};
|
|
reveal.onclick = () => {
|
|
if (shown) return mask();
|
|
shown = true;
|
|
value.replaceChildren(secret(item.password ?? ""));
|
|
reveal.replaceChildren(icon("eyeOff"));
|
|
reveal.setAttribute("aria-label", "Hide password");
|
|
// Re-mask after 30 seconds.
|
|
clearTimeout(remask);
|
|
remask = setTimeout(() => { if (value.isConnected) mask(); }, 30_000);
|
|
};
|
|
rows.push(fieldRow("Password", value, reveal, copyButton("Copy password", () => invoke("copy_secret", { id, field: "password" }))));
|
|
}
|
|
if (item.totp) {
|
|
let current: HTMLElement = h("span", "kv-code", "––– –––");
|
|
const value = h("span", "", current);
|
|
const secs = h("span", "kv-hint");
|
|
const r = ring();
|
|
const draw = async () => {
|
|
if (!current.isConnected) return;
|
|
const t = await invoke<{ code: string; seconds_remaining: number; period: number } | null>("totp", { id }).catch(() => null);
|
|
if (t) {
|
|
const next = codeText(t.code);
|
|
current.replaceWith(next);
|
|
current = next;
|
|
secs.textContent = `${t.seconds_remaining}s`;
|
|
r.update(t.seconds_remaining, t.period || 30);
|
|
}
|
|
setTimeout(draw, 1000);
|
|
};
|
|
rows.push(fieldRow("One-time code", value, secs, r.el, copyButton("Copy one-time code", () => invoke("copy_secret", { id, field: "totp" }))));
|
|
queueMicrotask(draw);
|
|
}
|
|
for (const u of item.urls) rows.push(fieldRow("Website", h("span", "kv-link", u)));
|
|
if (item.notes) rows.push(fieldRow("Notes", h("span", "notes", item.notes)));
|
|
const del = button("Delete", "danger", () => confirmModal(`Delete “${item.title}”?`, [p("kv-muted", "It's removed from every device that syncs with your server.")], "Delete",
|
|
async () => { await invoke("delete_item", { id }); selectedId = null; await render(); }, true));
|
|
target.replaceChildren(
|
|
h("div", "detail-head", tile(item.title), h("h2", "kv-title-2 grow", item.title), del),
|
|
h("div", "kv-card fields", ...rows),
|
|
);
|
|
}
|
|
|
|
function renderEditor(target: HTMLElement) {
|
|
const title = input({ autocomplete: "off" });
|
|
const user = input({ autocomplete: "off", spellcheck: false });
|
|
const pass = input({ autocomplete: "off" }, true);
|
|
const url = input({ type: "url", placeholder: "https://example.com", spellcheck: false });
|
|
const totp = input({ placeholder: "otpauth://totp/… or base32 secret" }, true);
|
|
const gen = button("Generate", "quiet", async () => { pass.value = await invoke<string>("generate_password", { length: 24 }); });
|
|
const err = errBox();
|
|
const save = button("Save", "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) {
|
|
setErr(err, e);
|
|
}
|
|
};
|
|
target.replaceChildren(h("div", "editor stack",
|
|
h("h2", "kv-title-2", "New login"),
|
|
field("Title", title), field("Username", user),
|
|
h("label", "kv-fieldset", h("span", "kv-label", "Password"), h("div", "row", pass, gen)),
|
|
field("Website", url), field("One-time code", totp, "Optional."),
|
|
err, h("div", "actions", save, button("Cancel", "secondary", () => target.replaceChildren(emptyDetail())))));
|
|
title.focus();
|
|
}
|
|
|
|
// ------------------------------------------------------------------ settings
|
|
|
|
function section(title: string, ...children: (Node | string | null)[]): HTMLElement {
|
|
return h("section", "settings-section", h("h3", "kv-title-3", title), ...children);
|
|
}
|
|
|
|
async function renderSettings(target: HTMLElement, state: AppState) {
|
|
const pairings = await invoke<Pairing[]>("list_pairings");
|
|
const idle = h("select", "kv-field", ...[1, 5, 10, 15, 30, 60, 120].map((m) => Object.assign(h("option", "", `${m} min`), { value: String(m), selected: m === state.idle_minutes })));
|
|
idle.onchange = () => invoke("set_idle_minutes", { minutes: Number(idle.value) });
|
|
idle.setAttribute("aria-label", "Lock after idle for");
|
|
const theme = h("div", "seg", ...(["system", "light", "dark"] as Theme[]).map((t) => {
|
|
const b = button(t === "system" ? "System" : t === "light" ? "Light" : "Dark", "quiet");
|
|
b.setAttribute("aria-pressed", String(storedTheme() === t));
|
|
b.onclick = () => {
|
|
setTheme(t);
|
|
theme.querySelectorAll("button").forEach((x) => x.setAttribute("aria-pressed", String(x === b)));
|
|
};
|
|
return b;
|
|
}));
|
|
theme.setAttribute("role", "group");
|
|
theme.setAttribute("aria-label", "Appearance");
|
|
const list = h("div", "kv-card rows");
|
|
for (const pr of pairings) {
|
|
list.append(h("div", "kv-frow",
|
|
h("div", "val", h("strong", "", `${pr.name}${pr.caller ? ` · ${pr.caller}` : ""}`), h("span", "kv-small kv-muted words-line", pr.words.join(" ")),
|
|
h("span", "kv-hint", `Paired ${new Date(pr.created_at * 1000).toLocaleString()}`)),
|
|
button("Unpair", "danger", async () => {
|
|
await invoke("revoke_pairing", { id: pr.id });
|
|
await renderSettings(target, state);
|
|
})));
|
|
}
|
|
if (!pairings.length) list.append(h("div", "kv-frow", p("kv-muted", "No browsers paired. Open the Kestrove extension in your browser and choose “Pair”.")));
|
|
const syncSection = h("div", "stack");
|
|
const devicesSection = h("div", "stack");
|
|
target.replaceChildren(h("div", "settings stack",
|
|
h("h2", "kv-title-2", "Settings"),
|
|
section("General",
|
|
h("div", "kv-card rows",
|
|
h("div", "kv-frow", h("div", "val", h("span", "", "Appearance")), theme),
|
|
h("div", "kv-frow", h("div", "val", h("span", "", "Lock after idle for"), h("span", "kv-hint", "Kestrove also locks on screen lock and sleep.")), idle))),
|
|
section("Sync", syncSection),
|
|
section("Devices", devicesSection),
|
|
section("Emergency Kit", kitSection()),
|
|
section("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(p("kv-muted", "Connect to your server first."));
|
|
}
|
|
|
|
function renderSyncSettings(box: HTMLElement, s: SyncStatus, reload: () => Promise<void>) {
|
|
const fact = (k: string, v: Node | string) => h("div", "kv-frow", h("div", "val", h("span", "kv-label", k), v));
|
|
const facts = h("div", "kv-card rows",
|
|
fact("Server", h("span", "", s.server_url ?? "—")),
|
|
fact("Certificate pins", h("span", "kv-mono kv-small", s.pins.length ? s.pins.join(", ") : "None (system certificates)")),
|
|
fact("Last sync", s.last_sync_at ? ago(s.last_sync_at) : "Never"),
|
|
fact("This device", h("span", "fingerprint", groups(s.device_fingerprint ?? "—"))),
|
|
fact("Device key", s.key_storage ?? "Unavailable"));
|
|
box.replaceChildren(facts);
|
|
if (s.key_warning) box.append(banner("warn", "Your device key is stored in a file", s.key_warning));
|
|
if (s.last_error) box.append(banner("bad", "Couldn't sync", s.last_error));
|
|
if (s.server_url && s.logged_in) return;
|
|
|
|
// A vault from before sync (or whose registration failed): connect it.
|
|
const url = input({ type: "url", placeholder: "https://kestrove.example.com", value: s.server_url ?? "" });
|
|
const pins = input({ value: s.pins.join(", ") }, true);
|
|
const email = input({ type: "email", autocomplete: "email", spellcheck: false });
|
|
const code = input({});
|
|
const err = errBox();
|
|
const btn = button("Connect", "primary");
|
|
btn.onclick = async () => {
|
|
btn.disabled = true;
|
|
err.replaceChildren();
|
|
try {
|
|
await invoke("configure_server", { serverUrl: url.value, pins: pins.value });
|
|
const setupOpen = await invoke<boolean>("server_setup_open");
|
|
if (setupOpen && !code.value.trim()) throw new Error("This server has no account yet: enter its setup code to register this vault.");
|
|
if (setupOpen || email.value.trim()) {
|
|
await invoke("register_server", { setupCode: code.value, email: email.value });
|
|
} else {
|
|
await invoke("sync_now");
|
|
}
|
|
await reload();
|
|
} catch (e) {
|
|
setErr(err, e);
|
|
btn.disabled = false;
|
|
}
|
|
};
|
|
box.append(h("div", "kv-card pad stack", field("Your Kestrove server", url), field("SPKI pins", pins, "Optional."),
|
|
field("Email", email, "To register this vault as a new account."),
|
|
field("Invite or setup code", code, "An admin's invite, or the setup code of a new server."), err, h("div", "actions", btn)));
|
|
}
|
|
|
|
async function renderDevices(box: HTMLElement) {
|
|
const err = errBox();
|
|
const list = h("div", "kv-card rows");
|
|
const reload = () => renderDevices(box);
|
|
let devices: DeviceView[] = [];
|
|
try {
|
|
devices = await invoke<DeviceView[]>("list_devices");
|
|
} catch (e) {
|
|
setErr(err, e);
|
|
}
|
|
for (const d of devices.filter((d) => !d.revoked)) {
|
|
const badges = h("span", "badges", d.current ? pill("off", "This device") : null, d.approved ? null : pill("wait", "Waiting for approval"));
|
|
const actions = h("div", "row");
|
|
if (!d.approved) {
|
|
actions.append(button("Approve…", "primary", () => confirmModal(
|
|
`Approve “${d.name}”?`,
|
|
[p("", "Only approve it if the new device shows exactly this fingerprint:"), p("fingerprint lg", groups(d.key_fingerprint))],
|
|
"The fingerprints match: approve",
|
|
async () => { await invoke("approve_device", { deviceId: d.device_id }); await reload(); })));
|
|
}
|
|
if (!d.current) {
|
|
actions.append(button(d.approved ? "Remove" : "Reject", "danger", () => confirmModal(`Remove “${d.name}”?`,
|
|
[p("kv-muted", "It will be signed out and can't sync again. Its local copy stays on it. To add it back later, set it up again as a new device.")], "Remove device",
|
|
async () => { await invoke("revoke_device", { deviceId: d.device_id }); await reload(); }, true)));
|
|
}
|
|
list.append(h("div", "kv-frow",
|
|
h("span", "device-ico", icon(/ios|android|phone|tablet/i.test(d.platform) ? "key" : "laptop")),
|
|
h("div", "val", h("span", "row", h("strong", "", d.name), h("span", "kv-small kv-muted", d.platform), badges),
|
|
h("span", "fingerprint kv-small kv-muted", groups(d.key_fingerprint)),
|
|
h("span", "kv-hint", `Added ${new Date(d.created_at).toLocaleString()}`)),
|
|
actions));
|
|
}
|
|
box.replaceChildren(
|
|
h("div", "row", button("Add a device…", "secondary", addDeviceModal), button("Approve a computer…", "secondary", approveComputerModal)),
|
|
list, err);
|
|
}
|
|
|
|
function kitSection(): HTMLElement {
|
|
const box = h("div", "stack");
|
|
const show = button("Show Emergency Kit setup code", "secondary", async () => {
|
|
try {
|
|
const qr = await invoke<string>("kit_qr");
|
|
box.replaceChildren(
|
|
banner("warn", "This code contains your Secret Key", "Keep it as safe as your Emergency Kit."),
|
|
h("div", "center-row", qrCard(qr, "Emergency Kit setup code")),
|
|
h("div", "actions", button("Hide", "quiet", () => box.replaceChildren(show))));
|
|
} catch (e) {
|
|
const err = errBox();
|
|
setErr(err, e);
|
|
box.append(err);
|
|
}
|
|
});
|
|
box.append(h("div", "actions", show));
|
|
return box;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ dialogs
|
|
|
|
function closeModal() {
|
|
modal.replaceChildren();
|
|
modal.hidden = true;
|
|
}
|
|
|
|
function openModal(...children: Node[]) {
|
|
const sheet = h("div", "kv-sheet prompt", ...children);
|
|
sheet.setAttribute("role", "dialog");
|
|
sheet.setAttribute("aria-modal", "true");
|
|
modal.replaceChildren(sheet);
|
|
modal.hidden = false;
|
|
sheet.querySelector<HTMLElement>("button.primary, textarea, input")?.focus();
|
|
}
|
|
|
|
function confirmModal(title: string, body: Node[], yes: string, action: () => Promise<void>, destructive = false) {
|
|
const err = errBox();
|
|
const ok = button(yes, destructive ? "danger-fill" : "primary");
|
|
ok.onclick = async () => {
|
|
ok.disabled = true;
|
|
try {
|
|
await action();
|
|
closeModal();
|
|
} catch (e) {
|
|
setErr(err, e);
|
|
ok.disabled = false;
|
|
}
|
|
};
|
|
openModal(h("h2", "kv-title-2", title), ...body, err, h("div", "actions end", button("Cancel", "secondary", closeModal), ok));
|
|
}
|
|
|
|
/// "Add a device": a single-use, 10-minute QR. Cancel revokes the ticket if it wasn't used.
|
|
async function addDeviceModal() {
|
|
let view: AddDeviceView;
|
|
try {
|
|
view = await invoke<AddDeviceView>("add_device");
|
|
} catch (e) {
|
|
const err = errBox();
|
|
setErr(err, e);
|
|
openModal(h("h2", "kv-title-2", "Add a device"), err, h("div", "actions end", button("Close", "secondary", closeModal)));
|
|
return;
|
|
}
|
|
const done = async () => {
|
|
closeModal();
|
|
await invoke("cancel_add_device");
|
|
};
|
|
openModal(h("div", "qr-sheet",
|
|
h("h2", "kv-title-2", "Add a device"),
|
|
steps("Open Kestrove on the new device.", h("span", "", "Choose ", h("b", "", "Scan setup QR"), "."), "Type your master password there."),
|
|
qrCard(view.qr_svg, "Setup code for a new device"),
|
|
countdown(view.expires_at, () => { if (!modal.hidden) void done(); }),
|
|
p("kv-hint", "It contains your Secret Key and works once. Don't show it to anyone else."),
|
|
button("Cancel", "quiet", done)));
|
|
}
|
|
|
|
/// Desktops usually have no camera: approve a new computer from its pasted join code.
|
|
function approveComputerModal() {
|
|
const text = h("textarea", "kv-field mono kv-small");
|
|
text.rows = 4;
|
|
text.placeholder = "kestrove://join/v1?…";
|
|
text.spellcheck = false;
|
|
const err = errBox();
|
|
const next = button("Continue", "primary");
|
|
const cancel = async () => {
|
|
await invoke("join_cancel");
|
|
closeModal();
|
|
};
|
|
next.onclick = async () => {
|
|
next.disabled = true;
|
|
err.replaceChildren();
|
|
try {
|
|
const c = await invoke<JoinConfirmView>("approve_join", { joinCode: text.value });
|
|
const approve = button("Approve", "primary", undefined, "block");
|
|
const err2 = errBox();
|
|
approve.onclick = async () => {
|
|
approve.disabled = true;
|
|
try {
|
|
await invoke("send_join");
|
|
openModal(h("h2", "kv-title-2", "Approved"), p("", `${c.device_name} will finish setting up and ask for your master password.`),
|
|
h("div", "actions end", button("Close", "primary", closeModal)));
|
|
} catch (e) {
|
|
setErr(err2, e);
|
|
approve.disabled = false;
|
|
}
|
|
};
|
|
openModal(h("div", "qr-sheet",
|
|
h("h2", "kv-title-2", "Approve this computer?"),
|
|
p("kv-small kv-muted", `${c.device_name} · ${c.platform}`, h("br"), "Check that the computer shows the same code."),
|
|
h("span", "kv-hint", "The computer should show:"),
|
|
codeText(c.code, true),
|
|
p("kv-hint", `Device key ${groups(c.key_fingerprint)}`),
|
|
err2,
|
|
approve,
|
|
button("The codes don't match", "secondary", cancel, "block")));
|
|
} catch (e) {
|
|
setErr(err, e);
|
|
next.disabled = false;
|
|
}
|
|
};
|
|
openModal(
|
|
h("h2", "kv-title-2", "Approve a computer"),
|
|
p("kv-muted", "On the new computer choose “Use my phone”, open “No phone? Approve from another computer” and copy the join code. Paste it here:"),
|
|
field("Join code", text),
|
|
err,
|
|
h("div", "actions end", button("Cancel", "secondary", cancel), next),
|
|
);
|
|
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(h("div", "kv-sheet prompt", banner("info", "Your browser asked to save a login", "Unlock Kestrove to review it.")));
|
|
}
|
|
overlay.hidden = !overlay.childElementCount;
|
|
}
|
|
|
|
function words(ws: string[]): HTMLElement {
|
|
return h("div", "words", ...ws.map((w) => h("span", "", w)));
|
|
}
|
|
|
|
function pairingCard(pr: PairingView, state: AppState): HTMLElement {
|
|
const err = errBox();
|
|
const accept = button("The words match: pair", "primary");
|
|
const reject = button("Don't pair", "secondary");
|
|
const resolve = async (yes: boolean) => {
|
|
try {
|
|
await invoke("resolve_pairing", { id: pr.id, accept: yes });
|
|
await render();
|
|
} catch (e) {
|
|
setErr(err, e);
|
|
}
|
|
};
|
|
accept.onclick = () => resolve(true);
|
|
reject.onclick = () => resolve(false);
|
|
accept.disabled = state.locked;
|
|
return h("div", "kv-sheet prompt",
|
|
h("h2", "kv-title-2", "Pair a browser?"),
|
|
p("", `${pr.name}${pr.caller ? ` (${pr.caller})` : ""} wants to use Kestrove. Check that the extension shows exactly these words:`),
|
|
words(pr.words),
|
|
state.locked ? p("kv-muted", "Unlock Kestrove first, then confirm.") : null,
|
|
err,
|
|
h("div", "actions end", reject, accept));
|
|
}
|
|
|
|
function saveCard(s: SaveView): HTMLElement {
|
|
const err = errBox();
|
|
const yes = button(s.update ? "Update password" : "Save", "primary");
|
|
const no = button("Not now", "secondary");
|
|
const resolve = async (accept: boolean) => {
|
|
try {
|
|
await invoke("resolve_save", { id: s.id, accept });
|
|
await render();
|
|
} catch (e) {
|
|
setErr(err, e);
|
|
}
|
|
};
|
|
yes.onclick = () => resolve(true);
|
|
no.onclick = () => resolve(false);
|
|
return h("div", "kv-sheet prompt",
|
|
h("h2", "kv-title-2", s.update ? "Update saved password?" : "Save this login?"),
|
|
h("div", "kv-card rows", h("div", "kv-frow", tile(s.origin.replace(/^https?:\/\/(www\.)?/, "")),
|
|
h("div", "val", h("strong", "", s.username || "No username"), h("span", "kv-small kv-muted", s.origin)))),
|
|
err,
|
|
h("div", "actions end", no, yes));
|
|
}
|
|
|
|
// ------------------------------------------------------------------ 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(integrityBanner(), h("div", "actions end", button("OK", "primary", 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();
|