Approve sites' passkey requests from the browser extension
The app comes forward with a prompt to sign in with or save a passkey, confirmed with the master password (the user verification each use needs). Asked while locked: unlock, then it continues. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+62
-1
@@ -14,6 +14,8 @@ 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 PasskeyChoice { credential_id: string; user_name: string; title: string }
|
||||
interface PasskeyView { id: string; kind: "get" | "create"; origin: string; rp_id: string; rp_name: string; user_name: string; choices: PasskeyChoice[]; locked: 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;
|
||||
@@ -938,12 +940,18 @@ function copyText(area: HTMLTextAreaElement) {
|
||||
else document.execCommand("copy");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ browser prompts (pairing, save)
|
||||
// ------------------------------------------------------------------ browser prompts (pairing, save, passkeys)
|
||||
|
||||
async function renderPrompts(state: AppState) {
|
||||
overlay.replaceChildren();
|
||||
const pairing = await invoke<PairingView | null>("pending_pairing");
|
||||
if (pairing) overlay.append(pairingCard(pairing, state));
|
||||
// Asked while locked: the unlock screen is behind; once unlocked the request shows its passkeys.
|
||||
for (const pk of await invoke<PasskeyView[]>("pending_passkeys")) {
|
||||
overlay.append(state.locked || pk.locked
|
||||
? h("div", "kv-sheet prompt", banner("info", `${pk.rp_id} asks for a passkey`, "Unlock Kestrove to continue."))
|
||||
: passkeyCard(pk));
|
||||
}
|
||||
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) {
|
||||
@@ -980,6 +988,57 @@ function pairingCard(pr: PairingView, state: AppState): HTMLElement {
|
||||
h("div", "actions end", reject, accept));
|
||||
}
|
||||
|
||||
/** A site's passkey request. Approving needs the master password: the user verification each use requires. */
|
||||
function passkeyCard(pk: PasskeyView): HTMLElement {
|
||||
const err = errBox();
|
||||
const create = pk.kind === "create";
|
||||
const pw = input({ type: "password", autocomplete: "current-password", placeholder: "Master password" });
|
||||
const yes = button(create ? "Save passkey" : "Sign in", "primary");
|
||||
const no = button(create ? "Use another device" : "Not now", "secondary");
|
||||
let chosen = pk.choices[0]?.credential_id ?? null;
|
||||
const list = create
|
||||
? h("div", "kv-card rows", h("div", "kv-frow", tile(pk.rp_id),
|
||||
h("div", "val", h("strong", "", pk.user_name || "This account"), h("span", "kv-small kv-muted", pk.rp_name ? `${pk.rp_name} · ${pk.rp_id}` : pk.rp_id))))
|
||||
: h("div", "kv-card rows", ...pk.choices.map((c, i) => {
|
||||
const radio = input({ type: "radio", name: `pk-${pk.id}`, checked: i === 0 });
|
||||
radio.onchange = () => { chosen = c.credential_id; };
|
||||
return h("label", "kv-frow", radio, tile(c.title),
|
||||
h("div", "val", h("strong", "", c.user_name || c.title), h("span", "kv-small kv-muted", c.title)));
|
||||
}));
|
||||
const resolve = async (accept: boolean) => {
|
||||
if (accept && !pw.value) {
|
||||
setErr(err, "Enter your master password to confirm it's you.");
|
||||
pw.focus();
|
||||
return;
|
||||
}
|
||||
yes.disabled = no.disabled = true;
|
||||
try {
|
||||
await invoke("resolve_passkey", { id: pk.id, accept, credentialId: accept ? chosen : null, masterPassword: accept ? pw.value : null });
|
||||
pw.value = "";
|
||||
await render();
|
||||
} catch (e) {
|
||||
setErr(err, e);
|
||||
yes.disabled = no.disabled = false;
|
||||
pw.select();
|
||||
}
|
||||
};
|
||||
yes.onclick = () => resolve(true);
|
||||
no.onclick = () => resolve(false);
|
||||
pw.onkeydown = (e) => {
|
||||
if (e.key === "Enter") void resolve(true);
|
||||
};
|
||||
queueMicrotask(() => pw.focus());
|
||||
return h("div", "kv-sheet prompt",
|
||||
h("h2", "kv-title-2", create ? "Save a passkey?" : "Sign in with a passkey?"),
|
||||
p("", create
|
||||
? `${pk.origin.replace(/^https?:\/\//, "")} wants to create a passkey. Kestrove keeps it with your logins and syncs it to your devices.`
|
||||
: `${pk.origin.replace(/^https?:\/\//, "")} asks you to sign in with a passkey.`),
|
||||
list,
|
||||
field("Confirm with your master password", pw),
|
||||
err,
|
||||
h("div", "actions end", no, yes));
|
||||
}
|
||||
|
||||
function saveCard(s: SaveView): HTMLElement {
|
||||
const err = errBox();
|
||||
const yes = button(s.update ? "Update password" : "Save", "primary");
|
||||
@@ -1006,6 +1065,8 @@ function saveCard(s: SaveView): HTMLElement {
|
||||
|
||||
void listen("pairing-request", () => render());
|
||||
void listen("save-request", () => render());
|
||||
void listen("passkey-request", () => render());
|
||||
void listen("passkey-settled", () => render());
|
||||
void listen("locked", () => {
|
||||
closeModal();
|
||||
void render();
|
||||
|
||||
Reference in New Issue
Block a user