Sync with the server and scan-to-join onboarding
- 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]>
This commit is contained in:
+600
-32
@@ -8,9 +8,21 @@ interface ItemDto { id: string; title: string; username: string | null; password
|
||||
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);
|
||||
@@ -19,43 +31,296 @@ function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Partial<HTMLEl
|
||||
}
|
||||
|
||||
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) renderCreate();
|
||||
if (!state.has_account) await renderOnboarding();
|
||||
else if (state.locked) renderUnlock(state);
|
||||
else await renderVault(state);
|
||||
await renderPrompts(state);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ account
|
||||
// ------------------------------------------------------------------ onboarding (docs/10)
|
||||
|
||||
function renderCreate() {
|
||||
const pw = el("input", { type: "password", placeholder: "Master password (10+ chars)" });
|
||||
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 btn = el("button", { textContent: "Create vault", className: "primary" });
|
||||
const card = el("div", { className: "card" }, el("h2", { textContent: "Create your Kestrove vault" }), pw, btn, err);
|
||||
btn.onclick = async () => {
|
||||
btn.disabled = true;
|
||||
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 secretKey = await invoke<string>("create_account", { masterPassword: pw.value });
|
||||
pw.value = "";
|
||||
card.replaceChildren(
|
||||
el("h2", { textContent: "Your Secret Key" }),
|
||||
el("p", { className: "mono", textContent: secretKey }),
|
||||
el("p", { className: "muted", textContent: "Write this down in your Emergency Kit. You need it to add devices. This device remembers it." }),
|
||||
el("button", { textContent: "Continue", className: "primary", onclick: render }),
|
||||
);
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
};
|
||||
app.append(el("div", { className: "center" }, card));
|
||||
pw.focus();
|
||||
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" });
|
||||
@@ -84,26 +349,86 @@ function renderUnlock(state: AppState) {
|
||||
|
||||
// ------------------------------------------------------------------ vault
|
||||
|
||||
let refreshList: (() => Promise<void>) | null = null;
|
||||
let syncBox: HTMLElement | null = null;
|
||||
|
||||
async function renderVault(state: AppState) {
|
||||
const items = await invoke<ItemSummary[]>("list_items");
|
||||
const detail = el("section");
|
||||
const list = el("ul");
|
||||
for (const it of items) {
|
||||
const li = el("li", {}, el("div", { textContent: it.title }), el("div", { className: "muted", textContent: it.username ?? "" }));
|
||||
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." }));
|
||||
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(); } });
|
||||
const aside = el("aside", {}, el("div", { className: "toolbar" }, add, settings, lock), list);
|
||||
if (state.ipc_error) aside.append(el("p", { className: "err pad", textContent: state.ipc_error }));
|
||||
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) {
|
||||
@@ -166,6 +491,8 @@ function renderEditor(target: HTMLElement) {
|
||||
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 })));
|
||||
@@ -187,13 +514,234 @@ async function renderSettings(target: HTMLElement, state: AppState) {
|
||||
);
|
||||
}
|
||||
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)
|
||||
@@ -260,7 +808,27 @@ function saveCard(s: SaveView): HTMLElement {
|
||||
|
||||
void listen("pairing-request", () => render());
|
||||
void listen("save-request", () => render());
|
||||
void listen("locked", () => 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;
|
||||
|
||||
Reference in New Issue
Block a user