keychain_max/apps/extension/src/popup.ts
2026-09-09 19:53:50 +00:00

122 lines
6.6 KiB
TypeScript

import {
decryptVault,
KeychainApiClient,
type User,
type VaultEntry,
} from "@keychain/core";
import { getSettings, originFromUrl } from "./extension";
import "./extension.css";
const rootElement = document.querySelector<HTMLDivElement>("#popup-app");
if (!rootElement) throw new Error("Popup root is missing");
const root = rootElement;
let activeTabId: number | undefined;
let activeOrigin: string | null = null;
let unlocked = false;
let entries: VaultEntry[] = [];
let busy = false;
let errorMessage = "";
function escapeHtml(value: string) {
return value.replace(/[&<>'"]/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" })[character] || character);
}
function initials(title: string) {
const words = title.trim().split(/\s+/).filter(Boolean);
return escapeHtml((words.length > 1 ? words[0][0] + words[1][0] : title.slice(0, 2)).toUpperCase());
}
function render() {
const originLabel = activeOrigin ? escapeHtml(activeOrigin.replace(/^https?:\/\//, "")) : "This page";
if (!unlocked) {
root.innerHTML = `
<div class="extension-shell">
<header class="extension-header"><div class="extension-brand"><span class="extension-mark">⌁</span><strong>Keychain</strong></div><span class="extension-lock">Locked</span></header>
<div class="extension-page-context"><span class="context-dot"></span><span>Fill for</span><strong>${originLabel}</strong></div>
<div class="extension-title"><span class="extension-kicker">Private by design</span><h1>Unlock to fill</h1><p>Your master password stays in this browser and is never sent to the server.</p></div>
<form id="unlock-extension" class="extension-form">
<label>Email<input name="email" type="email" placeholder="you@example.com" autocomplete="username" required /></label>
<label>Account password<input name="accountPassword" type="password" placeholder="Your account password" autocomplete="current-password" required /></label>
<label>Master password<input name="masterPassword" type="password" placeholder="Unlock your vault" autocomplete="new-password" required /></label>
${errorMessage ? `<div class="extension-error">${escapeHtml(errorMessage)}</div>` : ""}
<button class="extension-primary" type="submit" ${busy ? "disabled" : ""}>${busy ? "Unlocking…" : "Unlock vault <span>→</span>"}</button>
</form>
<footer class="extension-footer"><span>Encrypted locally</span><button id="open-options" type="button">Settings</button></footer>
</div>`;
document.querySelector<HTMLFormElement>("#unlock-extension")?.addEventListener("submit", unlock);
document.querySelector<HTMLButtonElement>("#open-options")?.addEventListener("click", () => chrome.runtime.openOptionsPage());
return;
}
const matches = entries.filter((entry) => originFromUrl(entry.url) === activeOrigin);
root.innerHTML = `
<div class="extension-shell">
<header class="extension-header"><div class="extension-brand"><span class="extension-mark">⌁</span><strong>Keychain</strong></div><button id="lock-extension" class="extension-lock extension-lock--button" type="button">Lock</button></header>
<div class="extension-page-context"><span class="context-dot"></span><span>Matches for</span><strong>${originLabel}</strong></div>
<div class="extension-title extension-title--compact"><span class="extension-kicker">Ready to fill</span><h1>${matches.length ? `${matches.length} login${matches.length === 1 ? "" : "s"} found` : "No login found"}</h1><p>${matches.length ? "Choose a login to fill. Keychain will not submit the form." : "Add a password with this website URL in your vault to use it here."}</p></div>
<div class="extension-matches">${matches.map((entry) => `<button class="match-card" data-entry-id="${escapeHtml(entry.id)}" type="button"><span class="match-logo">${initials(entry.title)}</span><span class="match-copy"><strong>${escapeHtml(entry.title)}</strong><span>${escapeHtml(entry.username || "No username")}</span></span><span class="match-arrow">→</span></button>`).join("") || `<div class="no-match"><span>⌕</span><strong>Nothing for this origin yet</strong><small>Exact origin matching protects you from accidental fills.</small></div>`}</div>
<footer class="extension-footer"><span>Unlocked in memory only</span><button id="open-options" type="button">Settings</button></footer>
</div>`;
document.querySelectorAll<HTMLButtonElement>("[data-entry-id]").forEach((button) => button.addEventListener("click", () => fill(button.dataset.entryId || "")));
document.querySelector<HTMLButtonElement>("#lock-extension")?.addEventListener("click", lock);
document.querySelector<HTMLButtonElement>("#open-options")?.addEventListener("click", () => chrome.runtime.openOptionsPage());
}
async function unlock(event: SubmitEvent) {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const data = new FormData(form);
busy = true;
errorMessage = "";
render();
try {
const settings = await getSettings();
const client = new KeychainApiClient(settings.apiUrl);
const user: User = await client.login(String(data.get("email")), String(data.get("accountPassword")));
const response = await client.getVault();
if (!response.envelope) throw new Error("This account has no encrypted vault yet");
const document = await decryptVault(response.envelope, String(data.get("masterPassword")), user.id);
entries = document.entries;
await chrome.runtime.sendMessage({ type: "SET_VAULT", entries });
unlocked = true;
} catch (error) {
errorMessage = error instanceof Error ? error.message : "Unable to unlock";
} finally {
busy = false;
render();
}
}
async function fill(entryId: string) {
if (!activeTabId) return;
const response = await chrome.runtime.sendMessage({ type: "FILL_ENTRY", entryId, tabId: activeTabId });
if (!response?.ok) {
errorMessage = response?.error || "Unable to fill this login";
render();
} else {
window.setTimeout(() => window.close(), 180);
}
}
async function lock() {
entries = [];
unlocked = false;
await chrome.runtime.sendMessage({ type: "LOCK" });
render();
}
async function start() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
activeTabId = tab?.id;
activeOrigin = originFromUrl(tab?.url);
const status = await chrome.runtime.sendMessage({ type: "STATUS" });
unlocked = Boolean(status?.unlocked);
if (unlocked && activeOrigin) {
const result = await chrome.runtime.sendMessage({ type: "MATCHING_ENTRIES", origin: activeOrigin });
entries = Array.isArray(result?.entries) ? result.entries : [];
}
render();
}
void start();