122 lines
6.6 KiB
TypeScript
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) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[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();
|