896 lines
28 KiB
JavaScript
896 lines
28 KiB
JavaScript
/**
|
|
* extension/popup/popup.js — PassKeeper popup UI
|
|
*
|
|
* Storage layout:
|
|
* chrome.storage.session — access_token, vault_key_jwk, vault_items (cleared on browser close)
|
|
* chrome.storage.local — refresh_token, enc_key_salt (persists across restarts)
|
|
*
|
|
* SSO flow (web app → extension):
|
|
* bridge.js syncs tokens to background → background writes to chrome.storage.
|
|
* On popup open: if session has no vault_key but local has enc_key_salt + refresh_token,
|
|
* we refresh the access_token and show the unlock-only view (master password only).
|
|
*/
|
|
|
|
const API_BASE = "https://pwkeeper.ngodanguyen.tech";
|
|
const VAULT_URL = "https://pwkeeper.ngodanguyen.tech/vault";
|
|
const ALERTS_URL = "https://pwkeeper.ngodanguyen.tech/vault#security";
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
|
|
let _vaultKey = null;
|
|
let _items = [];
|
|
let _mfaToken = null;
|
|
let _currentUrl = "";
|
|
let _activeTab = "relevant";
|
|
|
|
// ── DOM helpers ───────────────────────────────────────────────────────────────
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
function showView(name) {
|
|
["login", "mfa", "unlock", "vault", "generator"].forEach((v) =>
|
|
$(`view-${v}`).classList.toggle("hidden", v !== name),
|
|
);
|
|
// Bottom nav is only visible when the user is in the authenticated area.
|
|
const navViews = ["vault", "generator"];
|
|
$("main-bottom-nav").classList.toggle("hidden", !navViews.includes(name));
|
|
// Keep active state on nav buttons in sync.
|
|
["nav-vault", "nav-generator"].forEach((id) => {
|
|
const btn = $(id);
|
|
if (!btn) return;
|
|
const matches =
|
|
(id === "nav-vault" && name === "vault") ||
|
|
(id === "nav-generator" && name === "generator");
|
|
btn.classList.toggle("active", matches);
|
|
});
|
|
}
|
|
|
|
function showError(elId, msg) {
|
|
const el = $(elId);
|
|
el.textContent = msg;
|
|
el.classList.remove("hidden");
|
|
}
|
|
|
|
function hideError(elId) {
|
|
$(elId).classList.add("hidden");
|
|
}
|
|
|
|
function escHtml(str) {
|
|
return String(str ?? "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
// ── Avatar helpers ────────────────────────────────────────────────────────────
|
|
|
|
const AVATAR_COLORS = [
|
|
"",
|
|
"color-red",
|
|
"color-green",
|
|
"color-purple",
|
|
"color-teal",
|
|
];
|
|
|
|
function avatarColor(str) {
|
|
let h = 0;
|
|
for (const c of str) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
|
return AVATAR_COLORS[h % AVATAR_COLORS.length];
|
|
}
|
|
|
|
function itemEmoji(type) {
|
|
return (
|
|
{
|
|
password: "🔑",
|
|
note: "📝",
|
|
card: "💳",
|
|
bank: "🏦",
|
|
address: "🏠",
|
|
ssn: "🪪",
|
|
passkey: "🔐",
|
|
}[type] || "🔑"
|
|
);
|
|
}
|
|
|
|
function siteLabel(item) {
|
|
if (item.plain?.url) {
|
|
try {
|
|
return new URL(item.plain.url).hostname.replace(/^www\./, "");
|
|
} catch {}
|
|
}
|
|
return item.name;
|
|
}
|
|
|
|
// ── Domain matching ───────────────────────────────────────────────────────────
|
|
|
|
function currentHostname() {
|
|
if (!_currentUrl) return "";
|
|
try {
|
|
return new URL(_currentUrl).hostname.replace(/^www\./, "");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function isMatch(item) {
|
|
const host = currentHostname();
|
|
if (!host || item.item_type !== "password" || !item.plain?.url) return false;
|
|
try {
|
|
const h = new URL(item.plain.url).hostname.replace(/^www\./, "");
|
|
return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ── API helpers ───────────────────────────────────────────────────────────────
|
|
|
|
async function apiFetch(path, options = {}) {
|
|
const { access_token } = await chrome.storage.session.get("access_token");
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
...(options.headers || {}),
|
|
};
|
|
if (access_token) headers["Authorization"] = `Bearer ${access_token}`;
|
|
|
|
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
|
|
|
if (res.status === 401) {
|
|
const refreshed = await tryRefreshToken();
|
|
if (!refreshed) {
|
|
signOut();
|
|
return null;
|
|
}
|
|
const { access_token: tok } =
|
|
await chrome.storage.session.get("access_token");
|
|
headers["Authorization"] = `Bearer ${tok}`;
|
|
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
|
}
|
|
return res;
|
|
}
|
|
|
|
async function tryRefreshToken() {
|
|
const { refresh_token } = await chrome.storage.local.get("refresh_token");
|
|
if (!refresh_token) return false;
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/auth/refresh`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ refresh_token }),
|
|
});
|
|
if (!res.ok) return false;
|
|
const data = await res.json();
|
|
await chrome.storage.session.set({ access_token: data.access_token });
|
|
if (data.refresh_token)
|
|
await chrome.storage.local.set({ refresh_token: data.refresh_token });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
|
|
|
async function handleLogin() {
|
|
const email = $("login-email").value.trim().toLowerCase();
|
|
const password = $("login-password").value;
|
|
hideError("login-error");
|
|
if (!email || !password) {
|
|
showError("login-error", "Email and master password are required.");
|
|
return;
|
|
}
|
|
|
|
const btn = $("btn-login");
|
|
btn.disabled = true;
|
|
btn.textContent = "Unlocking…";
|
|
try {
|
|
const authHash = await ExtCrypto.deriveAuthHash(password, email);
|
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, auth_hash: authHash }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
showError("login-error", data.error || "Login failed.");
|
|
return;
|
|
}
|
|
|
|
if (data.mfa_required) {
|
|
_mfaToken = data.mfa_token;
|
|
await chrome.storage.session.set({
|
|
_pending_enc_key_salt: data.enc_key_salt,
|
|
});
|
|
handleMfaStage(password);
|
|
showView("mfa");
|
|
$("mfa-code").focus();
|
|
return;
|
|
}
|
|
await completeLogin(data, password);
|
|
} catch (err) {
|
|
showError("login-error", "Network error: " + err.message);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = "Unlock Vault";
|
|
}
|
|
}
|
|
|
|
function handleMfaStage(password) {
|
|
$("btn-mfa-verify").onclick = async () => {
|
|
const code = $("mfa-code").value.trim();
|
|
hideError("mfa-error");
|
|
if (code.length !== 6) {
|
|
showError("mfa-error", "Enter the 6-digit code.");
|
|
return;
|
|
}
|
|
$("btn-mfa-verify").disabled = true;
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/auth/mfa/verify`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ mfa_token: _mfaToken, totp_code: code }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
showError("mfa-error", data.error || "Verification failed.");
|
|
return;
|
|
}
|
|
_mfaToken = null;
|
|
const { _pending_enc_key_salt } = await chrome.storage.session.get(
|
|
"_pending_enc_key_salt",
|
|
);
|
|
await completeLogin(
|
|
{ ...data, enc_key_salt: _pending_enc_key_salt },
|
|
password,
|
|
);
|
|
} catch (err) {
|
|
showError("mfa-error", "Error: " + err.message);
|
|
} finally {
|
|
$("btn-mfa-verify").disabled = false;
|
|
}
|
|
};
|
|
}
|
|
|
|
async function completeLogin(data, masterPassword) {
|
|
await chrome.storage.session.set({
|
|
access_token: data.access_token,
|
|
enc_key_salt: data.enc_key_salt,
|
|
});
|
|
// enc_key_salt also persisted locally — not sensitive without the master password,
|
|
// needed to show unlock-only view after browser restart.
|
|
await chrome.storage.local.set({
|
|
refresh_token: data.refresh_token,
|
|
enc_key_salt: data.enc_key_salt,
|
|
});
|
|
|
|
_vaultKey = await ExtCrypto.deriveVaultKey(masterPassword, data.enc_key_salt);
|
|
const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey);
|
|
await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk });
|
|
|
|
// Push session to any open web app tabs so they don't need to re-login
|
|
chrome.runtime
|
|
.sendMessage({
|
|
type: "EXT_SESSION_SYNC",
|
|
access_token: data.access_token,
|
|
refresh_token: data.refresh_token,
|
|
enc_key_salt: data.enc_key_salt,
|
|
})
|
|
.catch(() => {});
|
|
|
|
showView("vault");
|
|
await checkPendingSave();
|
|
await fetchAndDecryptVault();
|
|
renderList();
|
|
}
|
|
|
|
// ── Unlock-only (tokens from web app, just need master password) ──────────────
|
|
|
|
async function handleUnlockOnly() {
|
|
const password = $("unlock-password").value;
|
|
hideError("unlock-error");
|
|
if (!password) {
|
|
showError("unlock-error", "Enter your master password.");
|
|
return;
|
|
}
|
|
|
|
const btn = $("btn-unlock");
|
|
btn.disabled = true;
|
|
btn.textContent = "Unlocking…";
|
|
try {
|
|
// enc_key_salt may be in session (fresh) or local (after browser restart)
|
|
let { enc_key_salt } = await chrome.storage.session.get("enc_key_salt");
|
|
if (!enc_key_salt) {
|
|
const local = await chrome.storage.local.get("enc_key_salt");
|
|
enc_key_salt = local.enc_key_salt;
|
|
}
|
|
if (!enc_key_salt) {
|
|
showError("unlock-error", "Session expired. Please log in again.");
|
|
return;
|
|
}
|
|
|
|
_vaultKey = await ExtCrypto.deriveVaultKey(password, enc_key_salt);
|
|
const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey);
|
|
await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk });
|
|
|
|
showView("vault");
|
|
await checkPendingSave();
|
|
await fetchAndDecryptVault();
|
|
renderList();
|
|
} catch {
|
|
showError("unlock-error", "Incorrect password or session expired.");
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = "Unlock";
|
|
}
|
|
}
|
|
|
|
async function signOut() {
|
|
try {
|
|
const { access_token } = await chrome.storage.session.get("access_token");
|
|
const { refresh_token } = await chrome.storage.local.get("refresh_token");
|
|
if (access_token) {
|
|
fetch(`${API_BASE}/api/auth/logout`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${access_token}`,
|
|
},
|
|
body: JSON.stringify({ refresh_token }),
|
|
}).catch(() => {});
|
|
}
|
|
} catch {}
|
|
await chrome.storage.session.clear();
|
|
await chrome.storage.local.remove(["refresh_token", "enc_key_salt"]);
|
|
_vaultKey = null;
|
|
_items = [];
|
|
showView("login");
|
|
}
|
|
|
|
// ── Vault loading ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Returns:
|
|
* 'vault' — fully unlocked session restored
|
|
* 'unlock' — tokens exist (from web app SSO) but vault key needs deriving
|
|
* false — no session, show login
|
|
*/
|
|
async function restoreSessionIfAvailable() {
|
|
const data = await chrome.storage.session.get([
|
|
"access_token",
|
|
"vault_key_jwk",
|
|
"vault_items",
|
|
"enc_key_salt",
|
|
]);
|
|
|
|
if (data.access_token) {
|
|
if (data.vault_key_jwk) {
|
|
_vaultKey = await ExtCrypto.importVaultKey(data.vault_key_jwk);
|
|
_items = data.vault_items || [];
|
|
return "vault";
|
|
}
|
|
// Tokens exist (synced from web app) but vault key not yet derived
|
|
if (data.enc_key_salt) return "unlock";
|
|
}
|
|
|
|
// Session cleared (e.g. browser restart) — try refreshing with persisted local tokens
|
|
const local = await chrome.storage.local.get([
|
|
"refresh_token",
|
|
"enc_key_salt",
|
|
]);
|
|
if (local.refresh_token && local.enc_key_salt) {
|
|
const refreshed = await tryRefreshToken();
|
|
if (refreshed) {
|
|
await chrome.storage.session.set({ enc_key_salt: local.enc_key_salt });
|
|
return "unlock";
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function fetchAndDecryptVault() {
|
|
$("vault-spinner").classList.remove("hidden");
|
|
try {
|
|
const res = await apiFetch("/api/vault");
|
|
if (!res) return;
|
|
const raw = await res.json();
|
|
|
|
_items = await Promise.all(
|
|
raw.map(async (item) => {
|
|
try {
|
|
const plain = await ExtCrypto.decryptItem(
|
|
_vaultKey,
|
|
item.enc_data,
|
|
item.iv,
|
|
);
|
|
return { ...item, plain };
|
|
} catch {
|
|
return { ...item, plain: null };
|
|
}
|
|
}),
|
|
);
|
|
|
|
await chrome.storage.session.set({ vault_items: _items });
|
|
chrome.runtime.sendMessage({ type: "VAULT_UPDATED" }).catch(() => {});
|
|
} catch (err) {
|
|
console.error("fetchAndDecryptVault:", err);
|
|
} finally {
|
|
$("vault-spinner").classList.add("hidden");
|
|
}
|
|
}
|
|
|
|
// ── Rendering ─────────────────────────────────────────────────────────────────
|
|
|
|
function getTabItems() {
|
|
const q = $("vault-search").value.trim().toLowerCase();
|
|
|
|
let items = _items;
|
|
|
|
// Text filter
|
|
if (q) {
|
|
items = items.filter(
|
|
(i) =>
|
|
i.name.toLowerCase().includes(q) ||
|
|
(i.plain?.username || "").toLowerCase().includes(q) ||
|
|
(i.plain?.url || "").toLowerCase().includes(q),
|
|
);
|
|
}
|
|
|
|
if (_activeTab === "relevant") {
|
|
// Show ONLY items that match the current domain, sorted by name.
|
|
items = items.filter(isMatch).sort((a, b) => a.name.localeCompare(b.name));
|
|
} else if (_activeTab === "recents") {
|
|
items = [...items]
|
|
.sort(
|
|
(a, b) =>
|
|
new Date(b.updated_at || b.created_at) -
|
|
new Date(a.updated_at || a.created_at),
|
|
)
|
|
.slice(0, 20);
|
|
} else {
|
|
items = [...items].sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
function renderList() {
|
|
const listEl = $("vault-list");
|
|
const emptyEl = $("vault-empty");
|
|
const items = getTabItems();
|
|
|
|
if (!items.length) {
|
|
listEl.innerHTML = "";
|
|
if (_activeTab === "relevant") {
|
|
const host = currentHostname();
|
|
emptyEl.textContent = host
|
|
? `No saved passwords for ${host}.`
|
|
: "No items match this page.";
|
|
} else {
|
|
emptyEl.textContent = "No items found.";
|
|
}
|
|
emptyEl.classList.remove("hidden");
|
|
return;
|
|
}
|
|
emptyEl.classList.add("hidden");
|
|
|
|
// SVG icons for action buttons
|
|
const svgCopy = `<svg viewBox="0 0 24 24" fill="none"><rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke="currentColor" stroke-width="1.7"/></svg>`;
|
|
const svgDots = `<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>`;
|
|
const svgFill = `<svg viewBox="0 0 24 24" fill="none"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M17 3l4 4-9 9H8v-4l9-9z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>`;
|
|
|
|
listEl.innerHTML = items
|
|
.map((item) => {
|
|
const matched = isMatch(item);
|
|
const site = escHtml(siteLabel(item));
|
|
const name = escHtml(item.name);
|
|
const badge = matched ? '<span class="badge-match">match</span>' : "";
|
|
const color = avatarColor(item.name);
|
|
const emoji = itemEmoji(item.item_type);
|
|
const canFill =
|
|
item.item_type === "password" &&
|
|
item.plain?.username &&
|
|
item.plain?.password;
|
|
const canCopy = item.item_type === "password" && item.plain?.password;
|
|
|
|
return `<div class="vault-item" data-id="${item.id}">
|
|
<div class="item-avatar ${color}">${emoji}</div>
|
|
<div class="item-info">
|
|
<div class="item-site">${site}${badge}</div>
|
|
<div class="item-name">${name}</div>
|
|
</div>
|
|
<div class="item-actions">
|
|
${canCopy ? `<button class="btn-item-action" data-copy-pass="${item.id}" title="Copy password">${svgCopy}</button>` : ""}
|
|
${canFill ? `<button class="btn-item-action" data-autofill="${item.id}" title="Autofill">${svgFill}</button>` : ""}
|
|
<button class="btn-item-action" data-menu="${item.id}" title="More">${svgDots}</button>
|
|
</div>
|
|
</div>`;
|
|
})
|
|
.join("");
|
|
|
|
// Copy password
|
|
listEl.querySelectorAll("[data-copy-pass]").forEach((btn) =>
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const item = _items.find((i) => i.id === parseInt(btn.dataset.copyPass));
|
|
if (item?.plain?.password) {
|
|
navigator.clipboard.writeText(item.plain.password);
|
|
btn.title = "Copied!";
|
|
setTimeout(() => {
|
|
btn.title = "Copy password";
|
|
}, 1500);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// Autofill
|
|
listEl.querySelectorAll("[data-autofill]").forEach((btn) =>
|
|
btn.addEventListener("click", async (e) => {
|
|
e.stopPropagation();
|
|
const item = _items.find((i) => i.id === parseInt(btn.dataset.autofill));
|
|
if (!item?.plain) return;
|
|
const [tab] = await chrome.tabs.query({
|
|
active: true,
|
|
currentWindow: true,
|
|
});
|
|
if (tab?.id) {
|
|
chrome.tabs
|
|
.sendMessage(tab.id, {
|
|
type: "DO_AUTOFILL",
|
|
username: item.plain.username || "",
|
|
password: item.plain.password || "",
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
window.close();
|
|
}),
|
|
);
|
|
|
|
// Three-dot menu: copy username
|
|
listEl.querySelectorAll("[data-menu]").forEach((btn) =>
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const item = _items.find((i) => i.id === parseInt(btn.dataset.menu));
|
|
if (!item?.plain) return;
|
|
// Simple: copy username on dots click (could be a dropdown in future)
|
|
if (item.plain.username) {
|
|
navigator.clipboard.writeText(item.plain.username);
|
|
btn.title = "Username copied!";
|
|
setTimeout(() => {
|
|
btn.title = "More";
|
|
}, 1500);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
|
|
|
function initTabs() {
|
|
document.querySelectorAll(".tab-btn").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
_activeTab = btn.dataset.tab;
|
|
document
|
|
.querySelectorAll(".tab-btn")
|
|
.forEach((b) => b.classList.toggle("active", b === btn));
|
|
renderList();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Save-prompt modal ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Show the save-prompt overlay. It is a blocking modal — no backdrop click,
|
|
* no ✕ button — so the user MUST click "Save" or "Not now".
|
|
* Called every time the popup opens while a pending_save is stored.
|
|
*
|
|
* pending_save is stored in chrome.storage.local (not session) so it survives
|
|
* service worker restarts and is reliably present when the popup re-opens.
|
|
*/
|
|
async function checkPendingSave() {
|
|
const { pending_save } = await chrome.storage.local.get("pending_save");
|
|
if (!pending_save) return;
|
|
|
|
// Populate fields.
|
|
$("save-name").value = pending_save.siteName || "";
|
|
$("save-username").value = pending_save.username || "";
|
|
$("save-prompt-subtitle").textContent =
|
|
`${pending_save.username || "(no username)"} on ${pending_save.siteName || pending_save.url || "unknown site"}`;
|
|
$("save-prompt-title").textContent = pending_save._isUpdate
|
|
? "Update in PassKeeper?"
|
|
: "Save to PassKeeper?";
|
|
|
|
// Show the overlay.
|
|
$("save-prompt-overlay").classList.remove("hidden");
|
|
$("save-name").focus();
|
|
|
|
// Wire buttons — use onclick so re-running checkPendingSave never double-binds.
|
|
$("btn-save-yes").onclick = async () => {
|
|
$("save-prompt-overlay").classList.add("hidden");
|
|
console.log(
|
|
"[PassKeeper] User saved credential for",
|
|
pending_save.siteName,
|
|
);
|
|
await saveCredential(pending_save);
|
|
await chrome.storage.local.remove("pending_save");
|
|
};
|
|
$("btn-save-no").onclick = async () => {
|
|
$("save-prompt-overlay").classList.add("hidden");
|
|
console.log(
|
|
"[PassKeeper] User dismissed save prompt for",
|
|
pending_save.siteName,
|
|
);
|
|
await chrome.storage.local.remove("pending_save");
|
|
};
|
|
}
|
|
|
|
async function saveCredential(data) {
|
|
if (!_vaultKey) return;
|
|
const plain = {
|
|
url: data.url || "",
|
|
username: data.username || "",
|
|
password: data.password || "",
|
|
notes: "",
|
|
};
|
|
const name = $("save-name").value.trim() || data.siteName || "Untitled";
|
|
const { enc_data, iv } = await ExtCrypto.encryptItem(_vaultKey, plain);
|
|
try {
|
|
const res = await apiFetch("/api/vault", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
name,
|
|
item_type: "password",
|
|
folder_id: null,
|
|
enc_data,
|
|
iv,
|
|
}),
|
|
});
|
|
if (res?.ok) {
|
|
await fetchAndDecryptVault();
|
|
renderList();
|
|
}
|
|
} catch (err) {
|
|
console.error("saveCredential failed:", err);
|
|
}
|
|
}
|
|
|
|
// ── Password Generator ────────────────────────────────────────────────────────
|
|
|
|
const GEN_SETS = {
|
|
lower: "abcdefghijklmnopqrstuvwxyz",
|
|
upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
|
numbers: "0123456789",
|
|
symbols: "!@#$%^&*()-_=+[]{}|;:,.<>?",
|
|
};
|
|
|
|
function generatePassword(length, useLower, useUpper, useNumbers, useSymbols) {
|
|
// Always fall back to lower if nothing selected, preventing infinite loop.
|
|
const pool = [
|
|
useLower ? GEN_SETS.lower : "",
|
|
useUpper ? GEN_SETS.upper : "",
|
|
useNumbers ? GEN_SETS.numbers : "",
|
|
useSymbols ? GEN_SETS.symbols : "",
|
|
].join("");
|
|
if (!pool) return "";
|
|
|
|
// Guarantee at least one character from each selected charset.
|
|
const required = [];
|
|
if (useLower)
|
|
required.push(
|
|
GEN_SETS.lower[Math.floor(Math.random() * GEN_SETS.lower.length)],
|
|
);
|
|
if (useUpper)
|
|
required.push(
|
|
GEN_SETS.upper[Math.floor(Math.random() * GEN_SETS.upper.length)],
|
|
);
|
|
if (useNumbers)
|
|
required.push(
|
|
GEN_SETS.numbers[Math.floor(Math.random() * GEN_SETS.numbers.length)],
|
|
);
|
|
if (useSymbols)
|
|
required.push(
|
|
GEN_SETS.symbols[Math.floor(Math.random() * GEN_SETS.symbols.length)],
|
|
);
|
|
|
|
const arr = new Uint32Array(length);
|
|
crypto.getRandomValues(arr);
|
|
const rest = Array.from(arr).map((n) => pool[n % pool.length]);
|
|
|
|
// Splice required chars into random positions and trim to length.
|
|
const combined = [...rest];
|
|
required.forEach((ch, i) => {
|
|
combined[i] = ch;
|
|
});
|
|
// Fisher-Yates shuffle for uniform distribution.
|
|
for (let i = combined.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[combined[i], combined[j]] = [combined[j], combined[i]];
|
|
}
|
|
return combined.slice(0, length).join("");
|
|
}
|
|
|
|
function passwordStrength(pw) {
|
|
if (!pw) return { label: "", color: "" };
|
|
let score = 0;
|
|
if (pw.length >= 8) score++;
|
|
if (pw.length >= 12) score++;
|
|
if (pw.length >= 16) score++;
|
|
if (/[a-z]/.test(pw)) score++;
|
|
if (/[A-Z]/.test(pw)) score++;
|
|
if (/[0-9]/.test(pw)) score++;
|
|
if (/[^a-zA-Z0-9]/.test(pw)) score++;
|
|
if (score <= 2) return { label: "Weak", color: "#dc2626" };
|
|
if (score <= 4) return { label: "Fair", color: "#d97706" };
|
|
if (score <= 5) return { label: "Good", color: "#2563eb" };
|
|
if (score <= 6) return { label: "Strong", color: "#16a34a" };
|
|
return { label: "Very Strong", color: "#15803d" };
|
|
}
|
|
|
|
let _genInitialised = false;
|
|
|
|
function initGenerator() {
|
|
// Only bind events once — subsequent calls just re-generate a password.
|
|
if (!_genInitialised) {
|
|
_genInitialised = true;
|
|
|
|
const slider = $("gen-length");
|
|
const numInput = $("gen-length-num");
|
|
|
|
// Keep slider and number input in sync.
|
|
slider.addEventListener("input", () => {
|
|
numInput.value = slider.value;
|
|
refreshGen();
|
|
});
|
|
numInput.addEventListener("input", () => {
|
|
const v = Math.min(64, Math.max(8, parseInt(numInput.value) || 16));
|
|
slider.value = v;
|
|
numInput.value = v;
|
|
refreshGen();
|
|
});
|
|
|
|
["gen-lower", "gen-upper", "gen-numbers", "gen-symbols"].forEach((id) => {
|
|
$(id).addEventListener("change", refreshGen);
|
|
});
|
|
|
|
$("btn-gen-refresh").addEventListener("click", refreshGen);
|
|
|
|
$("btn-gen-copy").addEventListener("click", () => {
|
|
const pw = $("gen-output").textContent;
|
|
if (!pw || pw === "Click refresh to generate") return;
|
|
navigator.clipboard.writeText(pw).then(() => {
|
|
$("btn-gen-copy").title = "Copied!";
|
|
setTimeout(() => {
|
|
$("btn-gen-copy").title = "Copy password";
|
|
}, 1500);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Always generate a fresh password when the view opens.
|
|
refreshGen();
|
|
}
|
|
|
|
function refreshGen() {
|
|
const length = Math.min(
|
|
64,
|
|
Math.max(8, parseInt($("gen-length").value) || 16),
|
|
);
|
|
const useLower = $("gen-lower").checked;
|
|
const useUpper = $("gen-upper").checked;
|
|
const useNumbers = $("gen-numbers").checked;
|
|
const useSymbols = $("gen-symbols").checked;
|
|
|
|
// Require at least one charset.
|
|
if (!useLower && !useUpper && !useNumbers && !useSymbols) {
|
|
$("gen-output").textContent = "Select at least one character type";
|
|
$("gen-strength-label").textContent = "";
|
|
return;
|
|
}
|
|
|
|
const pw = generatePassword(
|
|
length,
|
|
useLower,
|
|
useUpper,
|
|
useNumbers,
|
|
useSymbols,
|
|
);
|
|
$("gen-output").textContent = pw;
|
|
|
|
const { label, color } = passwordStrength(pw);
|
|
const strengthEl = $("gen-strength-label");
|
|
strengthEl.textContent = label;
|
|
strengthEl.style.color = color;
|
|
}
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
|
|
async function init() {
|
|
try {
|
|
const [tab] = await chrome.tabs.query({
|
|
active: true,
|
|
currentWindow: true,
|
|
});
|
|
_currentUrl = tab?.url || "";
|
|
} catch {}
|
|
|
|
// Set vault link
|
|
$("btn-open-vault").href = VAULT_URL;
|
|
|
|
const sessionState = await restoreSessionIfAvailable();
|
|
|
|
if (sessionState === "vault") {
|
|
showView("vault");
|
|
await checkPendingSave();
|
|
if (_items.length) {
|
|
renderList();
|
|
fetchAndDecryptVault().then(() => renderList());
|
|
} else {
|
|
await fetchAndDecryptVault();
|
|
renderList();
|
|
}
|
|
} else if (sessionState === "unlock") {
|
|
showView("unlock");
|
|
$("unlock-password").focus();
|
|
} else {
|
|
showView("login");
|
|
}
|
|
|
|
// Login
|
|
$("btn-login").addEventListener("click", handleLogin);
|
|
$("login-password").addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") handleLogin();
|
|
});
|
|
$("login-email").addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") $("login-password").focus();
|
|
});
|
|
|
|
// MFA
|
|
$("btn-mfa-back").addEventListener("click", () => {
|
|
_mfaToken = null;
|
|
showView("login");
|
|
});
|
|
$("mfa-code").addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") $("btn-mfa-verify")?.click();
|
|
});
|
|
|
|
// Unlock-only
|
|
$("btn-unlock").addEventListener("click", handleUnlockOnly);
|
|
$("unlock-password").addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") handleUnlockOnly();
|
|
});
|
|
$("btn-unlock-signout").addEventListener("click", signOut);
|
|
|
|
// Vault actions
|
|
$("btn-add-item").addEventListener("click", () => {
|
|
chrome.tabs.create({ url: VAULT_URL });
|
|
});
|
|
$("nav-account").addEventListener("click", signOut);
|
|
$("nav-vault").addEventListener("click", () => {
|
|
showView("vault");
|
|
});
|
|
$("nav-generator").addEventListener("click", () => {
|
|
showView("generator");
|
|
initGenerator();
|
|
});
|
|
$("nav-alerts").addEventListener("click", () => {
|
|
chrome.tabs.create({ url: ALERTS_URL });
|
|
});
|
|
|
|
// Keep the service worker alive so chrome.storage.session survives while the
|
|
// browser is open. We ping it every 20 s; the ping itself is a no-op but
|
|
// prevents the SW from being killed between popup openings.
|
|
const _keepalive = setInterval(() => {
|
|
chrome.runtime.sendMessage({ type: "KEEPALIVE" }).catch(() => {});
|
|
}, 20_000);
|
|
|
|
// Search
|
|
$("vault-search").addEventListener("input", () => renderList());
|
|
|
|
// Tabs
|
|
initTabs();
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", init);
|