diff --git a/extension/background.js b/extension/background.js
index dca6198..ae5e1da 100644
--- a/extension/background.js
+++ b/extension/background.js
@@ -33,6 +33,28 @@ async function applyIdleInterval() {
// Apply on every SW startup (SW can be killed and restarted at any time).
applyIdleInterval();
+// Allow content scripts to read chrome.storage.session.
+// By default, MV3 session storage is restricted to TRUSTED_CONTEXTS only
+// (background service worker + popup). Without this call, every
+// chrome.storage.session.get() in content.js silently returns undefined,
+// so vault_items_cs is never read and the autofill dropdown always shows
+// "No saved passwords for this site." even when credentials exist.
+chrome.storage.session.setAccessLevel(
+ { accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" },
+ () => {
+ if (chrome.runtime.lastError) {
+ console.warn(
+ "[PassKeeper] setAccessLevel failed:",
+ chrome.runtime.lastError.message,
+ );
+ } else {
+ console.log(
+ "[PassKeeper] session storage access level set to TRUSTED_AND_UNTRUSTED_CONTEXTS",
+ );
+ }
+ },
+);
+
chrome.idle.onStateChanged.addListener(async (newState) => {
if (newState === "idle" || newState === "locked") {
// Check if the user set "Never" before locking.
diff --git a/extension/popup/popup.js b/extension/popup/popup.js
index 891cf75..1aa77a0 100644
--- a/extension/popup/popup.js
+++ b/extension/popup/popup.js
@@ -11,54 +11,59 @@
* 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';
+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 _folders = []; // fetched alongside vault items for folder grouping
+let _folders = []; // fetched alongside vault items for folder grouping
let _mfaToken = null;
-let _currentUrl = '';
-let _activeTab = 'relevant';
-const _collapsedFolders = new Set(); // persists collapsed state across re-renders
+let _currentUrl = "";
+let _activeTab = "relevant";
+const _collapsedFolders = new Set(); // persists collapsed state across re-renders
// ── DOM helpers ───────────────────────────────────────────────────────────────
-const $ = id => document.getElementById(id);
+const $ = (id) => document.getElementById(id);
function showView(name) {
- ['login', 'mfa', 'unlock', 'vault', 'generator', 'account', 'add'].forEach(v =>
- $(`view-${v}`).classList.toggle('hidden', v !== name)
+ ["login", "mfa", "unlock", "vault", "generator", "account", "add"].forEach(
+ (v) => $(`view-${v}`).classList.toggle("hidden", v !== name),
);
// Bottom nav visible only in authenticated views.
- const navViews = ['vault', 'generator', 'account'];
- $('main-bottom-nav').classList.toggle('hidden', !navViews.includes(name));
+ const navViews = ["vault", "generator", "account"];
+ $("main-bottom-nav").classList.toggle("hidden", !navViews.includes(name));
// Sync active state on nav buttons.
- ['nav-vault', 'nav-generator', 'nav-account'].forEach(id => {
+ ["nav-vault", "nav-generator", "nav-account"].forEach((id) => {
const btn = $(id);
if (!btn) return;
- const active = (id === 'nav-vault' && name === 'vault') ||
- (id === 'nav-generator' && name === 'generator') ||
- (id === 'nav-account' && name === 'account');
- btn.classList.toggle('active', active);
+ const active =
+ (id === "nav-vault" && name === "vault") ||
+ (id === "nav-generator" && name === "generator") ||
+ (id === "nav-account" && name === "account");
+ btn.classList.toggle("active", active);
});
}
function showError(elId, msg) {
const el = $(elId);
el.textContent = msg;
- el.classList.remove('hidden');
+ el.classList.remove("hidden");
}
-function hideError(elId) { $(elId).classList.add('hidden'); }
+function hideError(elId) {
+ $(elId).classList.add("hidden");
+}
function escHtml(str) {
- return String(str ?? '')
- .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ return String(str ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
}
// Auto-clear clipboard 30 s after a sensitive copy.
@@ -67,14 +72,20 @@ function _copyWithAutoClear(text) {
navigator.clipboard.writeText(text).catch(() => {});
if (_clipTimer) clearTimeout(_clipTimer);
_clipTimer = setTimeout(() => {
- navigator.clipboard.writeText('').catch(() => {});
+ navigator.clipboard.writeText("").catch(() => {});
_clipTimer = null;
}, 30_000);
}
// ── Avatar helpers ────────────────────────────────────────────────────────────
-const AVATAR_COLORS = ['', 'color-red', 'color-green', 'color-purple', 'color-teal'];
+const AVATAR_COLORS = [
+ "",
+ "color-red",
+ "color-green",
+ "color-purple",
+ "color-teal",
+];
function avatarColor(str) {
let h = 0;
@@ -83,26 +94,42 @@ function avatarColor(str) {
}
function itemEmoji(type) {
- return { password: '🔑', note: '📝', card: '💳', bank: '🏦', address: '🏠', ssn: '🪪', passkey: '🔐' }[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 { }
+ try {
+ return new URL(item.plain.url).hostname.replace(/^www\./, "");
+ } catch {}
}
return item.name;
}
function folderName(id) {
- if (!id) return '(none)';
- return _folders.find(f => f.id === id)?.name || '(none)';
+ if (!id) return "(none)";
+ return _folders.find((f) => f.id === id)?.name || "(none)";
}
// ── Domain matching ───────────────────────────────────────────────────────────
function currentHostname() {
- if (!_currentUrl) return '';
- try { return new URL(_currentUrl).hostname.replace(/^www\./, ''); } catch { return ''; }
+ if (!_currentUrl) return "";
+ try {
+ return new URL(_currentUrl).hostname.replace(/^www\./, "");
+ } catch {
+ return "";
+ }
}
/**
@@ -113,138 +140,179 @@ function _normaliseUrl(raw) {
if (!raw) return null;
const s = raw.trim();
if (/^https?:\/\//i.test(s)) return s;
- if (s.startsWith('//')) return 'https:' + s;
- return 'https://' + s;
+ if (s.startsWith("//")) return "https:" + s;
+ return "https://" + s;
}
function isMatch(item) {
const host = currentHostname();
- if (!host || item.item_type !== 'password' || !item.plain?.url) return false;
+ if (!host || item.item_type !== "password" || !item.plain?.url) return false;
try {
const normalised = _normaliseUrl(item.plain.url);
if (!normalised) return false;
- const h = new URL(normalised).hostname.replace(/^www\./, '');
+ const h = new URL(normalised).hostname.replace(/^www\./, "");
// Match exact domain or any subdomain relationship.
return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`);
- } catch { return false; }
+ } 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}`;
+ 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}`;
+ 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');
+ 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' },
+ 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 });
+ if (data.refresh_token)
+ await chrome.storage.local.set({ refresh_token: data.refresh_token });
return true;
- } catch { return false; }
+ } 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 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…';
+ 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' },
+ 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 (!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 });
+ await chrome.storage.session.set({
+ _pending_enc_key_salt: data.enc_key_salt,
+ });
handleMfaStage(password);
- showView('mfa');
- $('mfa-code').focus();
+ showView("mfa");
+ $("mfa-code").focus();
return;
}
await completeLogin(data, password);
} catch (err) {
- showError('login-error', 'Network error: ' + err.message);
+ showError("login-error", "Network error: " + err.message);
} finally {
- btn.disabled = false; btn.textContent = 'Unlock Vault';
+ 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;
+ $("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' },
+ 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; }
+ 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);
+ 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);
+ showError("mfa-error", "Error: " + err.message);
} finally {
- $('btn-mfa-verify').disabled = false;
+ $("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 });
+ 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 });
+ 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(() => { });
+ 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');
+ showView("vault");
await checkPendingSave();
await fetchAndDecryptVault();
renderList();
@@ -253,53 +321,65 @@ async function completeLogin(data, masterPassword) {
// ── 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 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…';
+ 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');
+ 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');
+ 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; }
+ 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');
+ showView("vault");
await checkPendingSave();
await fetchAndDecryptVault();
renderList();
} catch {
- showError('unlock-error', 'Incorrect password or session expired.');
+ showError("unlock-error", "Incorrect password or session expired.");
} finally {
- btn.disabled = false; btn.textContent = 'Unlock';
+ 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');
+ 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}` },
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${access_token}`,
+ },
body: JSON.stringify({ refresh_token }),
- }).catch(() => { });
+ }).catch(() => {});
}
- } catch { }
+ } catch {}
await chrome.storage.session.clear();
- await chrome.storage.local.remove(['refresh_token', 'enc_key_salt']);
+ await chrome.storage.local.remove(["refresh_token", "enc_key_salt"]);
// vault_items_cs is now in session storage — cleared by the session.clear() call above.
- _vaultKey = null; _items = [];
- showView('login');
+ _vaultKey = null;
+ _items = [];
+ showView("login");
}
// ── Vault loading ─────────────────────────────────────────────────────────────
@@ -311,25 +391,33 @@ async function signOut() {
* 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']);
+ 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';
+ return "vault";
}
// Tokens exist (synced from web app) but vault key not yet derived
- if (data.enc_key_salt) return 'unlock';
+ 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']);
+ 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 "unlock";
}
}
@@ -337,31 +425,45 @@ async function restoreSessionIfAvailable() {
}
async function fetchAndDecryptVault() {
- $('vault-spinner').classList.remove('hidden');
+ $("vault-spinner").classList.remove("hidden");
try {
const [res, foldersRes] = await Promise.all([
- apiFetch('/api/vault'),
- apiFetch('/api/folders'),
+ apiFetch("/api/vault"),
+ apiFetch("/api/folders"),
]);
if (!res) return;
const raw = await res.json();
try {
if (foldersRes?.ok) _folders = await foldersRes.json();
- } catch (e) { _folders = []; }
+ } catch (e) {
+ _folders = [];
+ }
- _items = await Promise.all(raw.map(async item => {
- try {
- const plain = await ExtCrypto.decryptItem(_vaultKey, item.enc_data, item.iv);
- // Decrypt the item name if an encrypted version exists.
- // Fall back to the server-stored plaintext name for legacy items.
- let displayName = item.name;
- if (item.enc_name && item.iv_name) {
- const decrypted = await ExtCrypto.decryptName(_vaultKey, item.enc_name, item.iv_name);
- if (decrypted) displayName = decrypted;
+ _items = await Promise.all(
+ raw.map(async (item) => {
+ try {
+ const plain = await ExtCrypto.decryptItem(
+ _vaultKey,
+ item.enc_data,
+ item.iv,
+ );
+ // Decrypt the item name if an encrypted version exists.
+ // Fall back to the server-stored plaintext name for legacy items.
+ let displayName = item.name;
+ if (item.enc_name && item.iv_name) {
+ const decrypted = await ExtCrypto.decryptName(
+ _vaultKey,
+ item.enc_name,
+ item.iv_name,
+ );
+ if (decrypted) displayName = decrypted;
+ }
+ return { ...item, name: displayName, plain };
+ } catch {
+ return { ...item, plain: null };
}
- return { ...item, name: displayName, plain };
- } catch { return { ...item, plain: null }; }
- }));
+ }),
+ );
// Write to session for the popup's own use (badge, rendering).
await chrome.storage.session.set({ vault_items: _items });
@@ -369,7 +471,7 @@ async function fetchAndDecryptVault() {
// Write a lightweight copy to session storage for content scripts.
// session storage is memory-only (cleared on browser close) — decrypted
// vault data must never be persisted to disk via chrome.storage.local.
- const itemsForContentScript = _items.map(item => ({
+ const itemsForContentScript = _items.map((item) => ({
id: item.id,
name: item.name,
item_type: item.item_type,
@@ -378,14 +480,16 @@ async function fetchAndDecryptVault() {
await chrome.storage.session.set({ vault_items_cs: itemsForContentScript });
// Notify background to refresh badges and forward to content scripts.
- chrome.runtime.sendMessage({
- type: 'VAULT_UPDATED',
- vault_items: itemsForContentScript,
- }).catch(() => { });
+ chrome.runtime
+ .sendMessage({
+ type: "VAULT_UPDATED",
+ vault_items: itemsForContentScript,
+ })
+ .catch(() => {});
} catch (err) {
- console.error('fetchAndDecryptVault:', err);
+ console.error("fetchAndDecryptVault:", err);
} finally {
- $('vault-spinner').classList.add('hidden');
+ $("vault-spinner").classList.add("hidden");
}
}
@@ -394,26 +498,32 @@ async function fetchAndDecryptVault() {
function _extractTotpSecret(uri) {
if (!uri) return null;
uri = uri.trim();
- if (uri.startsWith('otpauth://')) {
+ if (uri.startsWith("otpauth://")) {
try {
- const secret = new URL(uri).searchParams.get('secret');
- return secret ? secret.toUpperCase().replace(/\s+/g, '') : null;
- } catch { return null; }
+ const secret = new URL(uri).searchParams.get("secret");
+ return secret ? secret.toUpperCase().replace(/\s+/g, "") : null;
+ } catch {
+ return null;
+ }
}
- return uri.toUpperCase().replace(/[\s-]/g, '') || null;
+ return uri.toUpperCase().replace(/[\s-]/g, "") || null;
}
function _base32ToBytes(b32) {
- const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
- b32 = b32.replace(/=+$/, '');
- let bits = 0, val = 0;
+ const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
+ b32 = b32.replace(/=+$/, "");
+ let bits = 0,
+ val = 0;
const out = [];
for (const c of b32) {
const idx = CHARS.indexOf(c);
if (idx === -1) continue;
val = (val << 5) | idx;
bits += 5;
- if (bits >= 8) { bits -= 8; out.push((val >> bits) & 0xff); }
+ if (bits >= 8) {
+ bits -= 8;
+ out.push((val >> bits) & 0xff);
+ }
}
return new Uint8Array(out);
}
@@ -427,16 +537,27 @@ async function getTotpCode(totpUri) {
const counter = Math.floor(Date.now() / 1000 / 30);
const msg = new Uint8Array(8);
let c = counter;
- for (let i = 7; i >= 0; i--) { msg[i] = c & 0xff; c = Math.floor(c / 256); }
+ for (let i = 7; i >= 0; i--) {
+ msg[i] = c & 0xff;
+ c = Math.floor(c / 256);
+ }
const cryptoKey = await crypto.subtle.importKey(
- 'raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']
+ "raw",
+ keyBytes,
+ { name: "HMAC", hash: "SHA-1" },
+ false,
+ ["sign"],
);
- const sig = new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, msg));
+ const sig = new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, msg));
const offset = sig[19] & 0x0f;
- const code = ((sig[offset] & 0x7f) << 24 | sig[offset + 1] << 16 |
- sig[offset + 2] << 8 | sig[offset + 3]) % 1_000_000;
- return String(code).padStart(6, '0');
+ const code =
+ (((sig[offset] & 0x7f) << 24) |
+ (sig[offset + 1] << 16) |
+ (sig[offset + 2] << 8) |
+ sig[offset + 3]) %
+ 1_000_000;
+ return String(code).padStart(6, "0");
}
function _totpSecondsLeft() {
@@ -447,33 +568,41 @@ function _totpSecondsLeft() {
let _totpIntervals = [];
function _clearTotpTickers() {
- _totpIntervals.forEach(id => clearInterval(id));
+ _totpIntervals.forEach((id) => clearInterval(id));
_totpIntervals = [];
}
// ── Rendering ─────────────────────────────────────────────────────────────────
function getTabItems() {
- const q = $('vault-search').value.trim().toLowerCase();
+ 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)
+ items = items.filter(
+ (i) =>
+ i.name.toLowerCase().includes(q) ||
+ (i.plain?.username || "").toLowerCase().includes(q) ||
+ (i.plain?.url || "").toLowerCase().includes(q),
);
}
- if (_activeTab === 'relevant') {
+ 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 if (_activeTab === 'favorites') {
- items = items.filter(i => (i.plain?.tags || []).includes('favorite'))
+ } 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 if (_activeTab === "favorites") {
+ items = items
+ .filter((i) => (i.plain?.tags || []).includes("favorite"))
.sort((a, b) => a.name.localeCompare(b.name));
} else {
items = [...items].sort((a, b) => a.name.localeCompare(b.name));
@@ -483,27 +612,27 @@ function getTabItems() {
}
function renderList() {
- const listEl = $('vault-list');
- const emptyEl = $('vault-empty');
+ const listEl = $("vault-list");
+ const emptyEl = $("vault-empty");
const items = getTabItems();
// Stop any running TOTP tickers from a previous render.
_clearTotpTickers();
if (!items.length) {
- listEl.innerHTML = '';
- if (_activeTab === 'relevant') {
+ listEl.innerHTML = "";
+ if (_activeTab === "relevant") {
const host = currentHostname();
emptyEl.textContent = host
? `No saved passwords for ${host}.`
- : 'No items match this page.';
+ : "No items match this page.";
} else {
- emptyEl.textContent = 'No items found.';
+ emptyEl.textContent = "No items found.";
}
- emptyEl.classList.remove('hidden');
+ emptyEl.classList.remove("hidden");
return;
}
- emptyEl.classList.add('hidden');
+ emptyEl.classList.add("hidden");
// SVG icons for action buttons
const svgCopy = ``;
@@ -516,51 +645,65 @@ function renderList() {
const matched = isMatch(item);
const site = escHtml(siteLabel(item));
const name = escHtml(item.name);
- const badge = matched ? 'match' : '';
+ const badge = matched ? 'match' : "";
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;
- const hasTotp = item.item_type === 'password' && !!_extractTotpSecret(item.plain?.totp_uri);
- const tags = (item.plain?.tags || []).filter(t => t !== 'favorite');
- const tagHtml = tags.map(t => `${escHtml(t)}`).join('');
- const isFav = (item.plain?.tags || []).includes('favorite');
+ const canFill =
+ item.item_type === "password" &&
+ item.plain?.username &&
+ item.plain?.password;
+ const canCopy = item.item_type === "password" && item.plain?.password;
+ const hasTotp =
+ item.item_type === "password" &&
+ !!_extractTotpSecret(item.plain?.totp_uri);
+ const tags = (item.plain?.tags || []).filter((t) => t !== "favorite");
+ const tagHtml = tags
+ .map((t) => `${escHtml(t)}`)
+ .join("");
+ const isFav = (item.plain?.tags || []).includes("favorite");
return `
${emoji}
-
${site}${badge}${isFav ? '★' : ''}
+
${site}${badge}${isFav ? '★' : ""}
${name}
- ${tagHtml ? `
${tagHtml}
` : ''}
- ${hasTotp ? `
······
` : ''}
+ ${tagHtml ? `
${tagHtml}
` : ""}
+ ${hasTotp ? `
······
` : ""}
- ${item.plain?.username ? `` : ''}
- ${canCopy ? `` : ''}
- ${hasTotp ? `` : ''}
- ${canFill ? `` : ''}
+ ${item.plain?.username ? `` : ""}
+ ${canCopy ? `` : ""}
+ ${hasTotp ? `` : ""}
+ ${canFill ? `` : ""}
`;
}
// Build the vault list — grouped by folder for 'all' tab, flat otherwise.
- if (_activeTab === 'all' && _folders.length > 0) {
+ if (_activeTab === "all" && _folders.length > 0) {
const groups = {};
- items.forEach(item => {
+ items.forEach((item) => {
const key = folderName(item.folder_id);
if (!groups[key]) groups[key] = [];
groups[key].push(item);
});
- const keys = ['(none)', ..._folders.map(f => f.name)].filter(k => groups[k]);
- Object.keys(groups).forEach(k => { if (!keys.includes(k)) keys.push(k); });
+ const keys = ["(none)", ..._folders.map((f) => f.name)].filter(
+ (k) => groups[k],
+ );
+ Object.keys(groups).forEach((k) => {
+ if (!keys.includes(k)) keys.push(k);
+ });
- listEl.innerHTML = keys.map(groupName => {
- if (!groups[groupName]) return '';
- const isCollapsed = _collapsedFolders.has(groupName);
- const count = groups[groupName].length;
- const chevron = isCollapsed ? '›' : '⌄';
- const itemsHtml = isCollapsed ? '' : groups[groupName].map(buildItemHtml).join('');
- return `
+ listEl.innerHTML = keys
+ .map((groupName) => {
+ if (!groups[groupName]) return "";
+ const isCollapsed = _collapsedFolders.has(groupName);
+ const count = groups[groupName].length;
+ const chevron = isCollapsed ? "›" : "⌄";
+ const itemsHtml = isCollapsed
+ ? ""
+ : groups[groupName].map(buildItemHtml).join("");
+ return `
`;
- }).join('');
+ })
+ .join("");
// Wire collapse toggle on group headers.
- listEl.querySelectorAll('.pk-group-header').forEach(hdr => {
- hdr.addEventListener('click', () => {
- const groupName = hdr.closest('.pk-group').dataset.group;
- if (_collapsedFolders.has(groupName)) _collapsedFolders.delete(groupName);
+ listEl.querySelectorAll(".pk-group-header").forEach((hdr) => {
+ hdr.addEventListener("click", () => {
+ const groupName = hdr.closest(".pk-group").dataset.group;
+ if (_collapsedFolders.has(groupName))
+ _collapsedFolders.delete(groupName);
else _collapsedFolders.add(groupName);
renderList();
});
});
} else {
- listEl.innerHTML = items.map(buildItemHtml).join('');
+ listEl.innerHTML = items.map(buildItemHtml).join("");
}
// Start TOTP tickers for items that have a totp_uri.
- items.forEach(item => {
- if (item.item_type !== 'password' || !_extractTotpSecret(item.plain?.totp_uri)) return;
+ items.forEach((item) => {
+ if (
+ item.item_type !== "password" ||
+ !_extractTotpSecret(item.plain?.totp_uri)
+ )
+ return;
const codeEl = $(`totp-${item.id}`);
const timerEl = $(`totp-t-${item.id}`);
if (!codeEl) return;
@@ -595,10 +744,10 @@ function renderList() {
async function tick() {
const code = await getTotpCode(item.plain.totp_uri).catch(() => null);
if (!code || !codeEl.isConnected) return;
- codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3);
+ codeEl.textContent = code.slice(0, 3) + " " + code.slice(3);
const secs = _totpSecondsLeft();
- timerEl.textContent = ' ' + secs + 's';
- timerEl.style.color = secs <= 5 ? '#dc2626' : '#9ca3af';
+ timerEl.textContent = " " + secs + "s";
+ timerEl.style.color = secs <= 5 ? "#dc2626" : "#9ca3af";
}
tick();
const id = setInterval(tick, 1000);
@@ -606,126 +755,158 @@ function renderList() {
});
// Copy password
- listEl.querySelectorAll('[data-copy-pass]').forEach(btn =>
- btn.addEventListener('click', e => {
+ listEl.querySelectorAll("[data-copy-pass]").forEach((btn) =>
+ btn.addEventListener("click", (e) => {
e.stopPropagation();
- const item = _items.find(i => i.id === parseInt(btn.dataset.copyPass));
+ const item = _items.find((i) => i.id === parseInt(btn.dataset.copyPass));
if (item?.plain?.password) {
_copyWithAutoClear(item.plain.password);
- btn.title = 'Copied!';
- setTimeout(() => { btn.title = 'Copy password'; }, 1500);
+ btn.title = "Copied!";
+ setTimeout(() => {
+ btn.title = "Copy password";
+ }, 1500);
}
- })
+ }),
);
// Copy TOTP code
- listEl.querySelectorAll('[data-copy-totp]').forEach(btn =>
- btn.addEventListener('click', async e => {
+ listEl.querySelectorAll("[data-copy-totp]").forEach((btn) =>
+ btn.addEventListener("click", async (e) => {
e.stopPropagation();
- const item = _items.find(i => i.id === parseInt(btn.dataset.copyTotp));
+ const item = _items.find((i) => i.id === parseInt(btn.dataset.copyTotp));
if (!item?.plain?.totp_uri) return;
const code = await getTotpCode(item.plain.totp_uri).catch(() => null);
if (code) {
navigator.clipboard.writeText(code);
- btn.title = 'Copied!';
- btn.style.color = '#16a34a';
- setTimeout(() => { btn.title = 'Copy 2FA code'; btn.style.color = ''; }, 1500);
+ btn.title = "Copied!";
+ btn.style.color = "#16a34a";
+ setTimeout(() => {
+ btn.title = "Copy 2FA code";
+ btn.style.color = "";
+ }, 1500);
}
- })
+ }),
);
// Autofill
- listEl.querySelectorAll('[data-autofill]').forEach(btn =>
- btn.addEventListener('click', async e => {
+ listEl.querySelectorAll("[data-autofill]").forEach((btn) =>
+ btn.addEventListener("click", async (e) => {
e.stopPropagation();
- const item = _items.find(i => i.id === parseInt(btn.dataset.autofill));
+ 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 });
+ 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(() => { });
+ chrome.tabs
+ .sendMessage(tab.id, {
+ type: "DO_AUTOFILL",
+ username: item.plain.username || "",
+ password: item.plain.password || "",
+ })
+ .catch(() => {});
}
window.close();
- })
+ }),
);
// Copy username (dedicated button)
- listEl.querySelectorAll('[data-copy-user]').forEach(btn =>
- btn.addEventListener('click', e => {
+ listEl.querySelectorAll("[data-copy-user]").forEach((btn) =>
+ btn.addEventListener("click", (e) => {
e.stopPropagation();
- const item = _items.find(i => i.id === parseInt(btn.dataset.copyUser));
+ const item = _items.find((i) => i.id === parseInt(btn.dataset.copyUser));
if (item?.plain?.username) {
_copyWithAutoClear(item.plain.username);
- btn.title = 'Copied!';
- setTimeout(() => { btn.title = 'Copy username'; }, 1500);
+ btn.title = "Copied!";
+ setTimeout(() => {
+ btn.title = "Copy username";
+ }, 1500);
}
- })
+ }),
);
// Three-dot menu: proper flyout with contextual actions
- listEl.querySelectorAll('[data-menu]').forEach(btn =>
- btn.addEventListener('click', e => {
+ listEl.querySelectorAll("[data-menu]").forEach((btn) =>
+ btn.addEventListener("click", (e) => {
e.stopPropagation();
// Close any already-open flyout first.
- document.querySelectorAll('.pk-flyout').forEach(el => el.remove());
+ document.querySelectorAll(".pk-flyout").forEach((el) => el.remove());
- const item = _items.find(i => i.id === parseInt(btn.dataset.menu));
+ const item = _items.find((i) => i.id === parseInt(btn.dataset.menu));
if (!item) return;
- const flyout = document.createElement('div');
- flyout.className = 'pk-flyout';
+ const flyout = document.createElement("div");
+ flyout.className = "pk-flyout";
const menuItems = [
- item.plain?.url ? {
- label: 'Open URL',
- icon: '
',
- action: () => { chrome.tabs.create({ url: item.plain.url }); flyout.remove(); },
- } : null,
- item.plain?.username ? {
- label: 'Copy username',
- icon: '
',
- action: () => { _copyWithAutoClear(item.plain.username); flyout.remove(); },
- } : null,
- item.plain?.password ? {
- label: 'Copy password',
- icon: '
',
- action: () => { _copyWithAutoClear(item.plain.password); flyout.remove(); },
- } : null,
+ item.plain?.url
+ ? {
+ label: "Open URL",
+ icon: '
',
+ action: () => {
+ chrome.tabs.create({ url: item.plain.url });
+ flyout.remove();
+ },
+ }
+ : null,
+ item.plain?.username
+ ? {
+ label: "Copy username",
+ icon: '
',
+ action: () => {
+ _copyWithAutoClear(item.plain.username);
+ flyout.remove();
+ },
+ }
+ : null,
+ item.plain?.password
+ ? {
+ label: "Copy password",
+ icon: '
',
+ action: () => {
+ _copyWithAutoClear(item.plain.password);
+ flyout.remove();
+ },
+ }
+ : null,
].filter(Boolean);
- menuItems.forEach(mi => {
- const row = document.createElement('button');
- row.className = 'pk-flyout-item';
- row.innerHTML = mi.icon + '
' + escHtml(mi.label) + '';
- row.addEventListener('click', e => { e.stopPropagation(); mi.action(); });
+ menuItems.forEach((mi) => {
+ const row = document.createElement("button");
+ row.className = "pk-flyout-item";
+ row.innerHTML = mi.icon + "
" + escHtml(mi.label) + "";
+ row.addEventListener("click", (e) => {
+ e.stopPropagation();
+ mi.action();
+ });
flyout.appendChild(row);
});
// Position flyout below the button, right-aligned.
- btn.style.position = 'relative';
+ btn.style.position = "relative";
btn.appendChild(flyout);
// Close on any outside click.
setTimeout(() => {
- document.addEventListener('click', function handler() {
+ document.addEventListener("click", function handler() {
flyout.remove();
- document.removeEventListener('click', handler);
+ document.removeEventListener("click", handler);
});
}, 0);
- })
+ }),
);
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
function initTabs() {
- document.querySelectorAll('.tab-btn').forEach(btn => {
- btn.addEventListener('click', () => {
+ 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));
+ document
+ .querySelectorAll(".tab-btn")
+ .forEach((b) => b.classList.toggle("active", b === btn));
renderList();
});
});
@@ -739,21 +920,21 @@ function initTabs() {
* The first option ("— No folder —") with value "" is always kept.
*/
async function loadFoldersIntoSaveModal() {
- const select = $('save-folder');
+ const select = $("save-folder");
// Reset to just the placeholder option.
select.innerHTML = '
';
try {
- const res = await apiFetch('/api/folders');
+ const res = await apiFetch("/api/folders");
if (!res?.ok) return;
const folders = await res.json();
- folders.forEach(f => {
- const opt = document.createElement('option');
+ folders.forEach((f) => {
+ const opt = document.createElement("option");
opt.value = f.id;
opt.textContent = f.name;
select.appendChild(opt);
});
} catch (err) {
- console.error('[PassKeeper] loadFoldersIntoSaveModal failed:', err);
+ console.error("[PassKeeper] loadFoldersIntoSaveModal failed:", err);
}
}
@@ -766,73 +947,100 @@ async function loadFoldersIntoSaveModal() {
* 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');
+ 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?';
+ $("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?";
// Load folders (non-blocking — modal shows immediately, folders populate async).
- $('save-folder').value = '';
+ $("save-folder").value = "";
loadFoldersIntoSaveModal();
// Show the overlay.
- $('save-prompt-overlay').classList.remove('hidden');
- $('save-name').focus();
+ $("save-prompt-overlay").classList.remove("hidden");
+ $("save-name").focus();
// Wire buttons — use onclick so re-running checkPendingSave never double-binds.
// Helper to dismiss the overlay, clear storage, and remove the toolbar badge.
function _clearPendingSave() {
- $('save-prompt-overlay').classList.add('hidden');
- chrome.storage.local.remove('pending_save');
- chrome.runtime.sendMessage({ type: 'CLEAR_SAVE_BADGE' }).catch(() => {});
+ $("save-prompt-overlay").classList.add("hidden");
+ chrome.storage.local.remove("pending_save");
+ chrome.runtime.sendMessage({ type: "CLEAR_SAVE_BADGE" }).catch(() => {});
}
- $('btn-save-yes').onclick = async () => {
- console.log('[PassKeeper] User saved credential for', pending_save.siteName);
+ $("btn-save-yes").onclick = async () => {
+ console.log(
+ "[PassKeeper] User saved credential for",
+ pending_save.siteName,
+ );
await saveCredential(pending_save);
_clearPendingSave();
};
- $('btn-save-no').onclick = () => {
- console.log('[PassKeeper] User dismissed save prompt for', pending_save.siteName);
+ $("btn-save-no").onclick = () => {
+ console.log(
+ "[PassKeeper] User dismissed save prompt for",
+ pending_save.siteName,
+ );
_clearPendingSave();
};
}
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 plain = {
+ url: data.url || "",
+ username: data.username || "",
+ password: data.password || "",
+ notes: "",
+ };
+ const name = $("save-name").value.trim() || data.siteName || "Untitled";
// Read selected folder — empty string means no folder (null).
- const folderVal = $('save-folder').value;
+ const folderVal = $("save-folder").value;
const folder_id = folderVal ? parseInt(folderVal, 10) : null;
const { enc_data, iv } = await ExtCrypto.encryptItem(_vaultKey, plain);
const { enc_name, iv_name } = await ExtCrypto.encryptName(_vaultKey, name);
try {
- const res = await apiFetch('/api/vault', {
- method: 'POST',
- body: JSON.stringify({ name: 'password', item_type: 'password', folder_id, enc_data, iv, enc_name, iv_name }),
+ const res = await apiFetch("/api/vault", {
+ method: "POST",
+ body: JSON.stringify({
+ name: "password",
+ item_type: "password",
+ folder_id,
+ enc_data,
+ iv,
+ enc_name,
+ iv_name,
+ }),
});
if (res?.ok) {
- console.log('[PassKeeper] Credential saved to vault:', name, '(name encrypted), folder_id:', folder_id);
+ console.log(
+ "[PassKeeper] Credential saved to vault:",
+ name,
+ "(name encrypted), folder_id:",
+ folder_id,
+ );
await fetchAndDecryptVault();
renderList();
}
- } catch (err) { console.error('saveCredential failed:', err); }
+ } catch (err) {
+ console.error("saveCredential failed:", err);
+ }
}
// ── Password Generator ────────────────────────────────────────────────────────
const GEN_SETS = {
- lower: 'abcdefghijklmnopqrstuvwxyz',
- upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
- numbers: '0123456789',
- symbols: '!@#$%^&*()-_=+[]{}|;:,.<>?',
+ lower: "abcdefghijklmnopqrstuvwxyz",
+ upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
+ numbers: "0123456789",
+ symbols: "!@#$%^&*()-_=+[]{}|;:,.<>?",
};
/**
@@ -843,45 +1051,53 @@ function _cryptoRandInt(max) {
// Rejection sampling to eliminate modulo bias.
const limit = Math.floor(0x100000000 / max) * max;
const buf = new Uint32Array(1);
- do { crypto.getRandomValues(buf); } while (buf[0] >= limit);
+ do {
+ crypto.getRandomValues(buf);
+ } while (buf[0] >= limit);
return buf[0] % max;
}
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 '';
+ 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
// using cryptographically secure random selection.
const required = [];
- if (useLower) required.push(GEN_SETS.lower[_cryptoRandInt(GEN_SETS.lower.length)]);
- if (useUpper) required.push(GEN_SETS.upper[_cryptoRandInt(GEN_SETS.upper.length)]);
- if (useNumbers) required.push(GEN_SETS.numbers[_cryptoRandInt(GEN_SETS.numbers.length)]);
- if (useSymbols) required.push(GEN_SETS.symbols[_cryptoRandInt(GEN_SETS.symbols.length)]);
+ if (useLower)
+ required.push(GEN_SETS.lower[_cryptoRandInt(GEN_SETS.lower.length)]);
+ if (useUpper)
+ required.push(GEN_SETS.upper[_cryptoRandInt(GEN_SETS.upper.length)]);
+ if (useNumbers)
+ required.push(GEN_SETS.numbers[_cryptoRandInt(GEN_SETS.numbers.length)]);
+ if (useSymbols)
+ required.push(GEN_SETS.symbols[_cryptoRandInt(GEN_SETS.symbols.length)]);
const arr = new Uint32Array(length);
crypto.getRandomValues(arr);
- const rest = Array.from(arr).map(n => pool[n % pool.length]);
+ 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; });
+ required.forEach((ch, i) => {
+ combined[i] = ch;
+ });
// Fisher-Yates shuffle — fully CSPRNG, no Math.random().
for (let i = combined.length - 1; i > 0; i--) {
const j = _cryptoRandInt(i + 1);
[combined[i], combined[j]] = [combined[j], combined[i]];
}
- return combined.slice(0, length).join('');
+ return combined.slice(0, length).join("");
}
function passwordStrength(pw) {
- if (!pw) return { label: '', color: '' };
+ if (!pw) return { label: "", color: "" };
let score = 0;
if (pw.length >= 8) score++;
if (pw.length >= 12) score++;
@@ -890,11 +1106,11 @@ function passwordStrength(pw) {
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' };
+ 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;
@@ -904,33 +1120,35 @@ function initGenerator() {
if (!_genInitialised) {
_genInitialised = true;
- const slider = $('gen-length');
- const numInput = $('gen-length-num');
+ const slider = $("gen-length");
+ const numInput = $("gen-length-num");
// Keep slider and number input in sync.
- slider.addEventListener('input', () => {
+ slider.addEventListener("input", () => {
numInput.value = slider.value;
refreshGen();
});
- numInput.addEventListener('input', () => {
+ 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);
+ ["gen-lower", "gen-upper", "gen-numbers", "gen-symbols"].forEach((id) => {
+ $(id).addEventListener("change", refreshGen);
});
- $('btn-gen-refresh').addEventListener('click', 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;
+ $("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);
+ $("btn-gen-copy").title = "Copied!";
+ setTimeout(() => {
+ $("btn-gen-copy").title = "Copy password";
+ }, 1500);
});
});
}
@@ -940,24 +1158,33 @@ function initGenerator() {
}
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;
+ 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 = '';
+ $("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 pw = generatePassword(
+ length,
+ useLower,
+ useUpper,
+ useNumbers,
+ useSymbols,
+ );
+ $("gen-output").textContent = pw;
const { label, color } = passwordStrength(pw);
- const strengthEl = $('gen-strength-label');
+ const strengthEl = $("gen-strength-label");
strengthEl.textContent = label;
strengthEl.style.color = color;
}
@@ -969,180 +1196,211 @@ let _addViewInitialised = false;
async function initAddView() {
// Pre-fill URL from the current active tab.
try {
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
- if (tab?.url?.startsWith('http')) {
- $('add-url').value = tab.url;
+ const [tab] = await chrome.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ if (tab?.url?.startsWith("http")) {
+ $("add-url").value = tab.url;
// Derive a human-friendly site name from the tab title or hostname.
- if (!$('add-name').value) {
- $('add-name').value = tab.title
- ? tab.title.replace(/[-|–].*$/, '').trim() // strip " - Login" suffixes
- : new URL(tab.url).hostname.replace(/^www\./, '');
+ if (!$("add-name").value) {
+ $("add-name").value = tab.title
+ ? tab.title.replace(/[-|–].*$/, "").trim() // strip " - Login" suffixes
+ : new URL(tab.url).hostname.replace(/^www\./, "");
}
}
- } catch (e) { }
+ } catch (e) {}
// Load folders into the add-folder select.
- const addFolderSel = $('add-folder');
+ const addFolderSel = $("add-folder");
addFolderSel.innerHTML = '
';
try {
- const res = await apiFetch('/api/folders');
+ const res = await apiFetch("/api/folders");
if (res?.ok) {
const folders = await res.json();
- folders.forEach(f => {
- const opt = document.createElement('option');
+ folders.forEach((f) => {
+ const opt = document.createElement("option");
opt.value = f.id;
opt.textContent = f.name;
addFolderSel.appendChild(opt);
});
}
- } catch (e) { }
+ } catch (e) {}
// Wire one-time event listeners.
if (!_addViewInitialised) {
_addViewInitialised = true;
// Back button.
- $('btn-add-back').addEventListener('click', () => {
- showView('vault');
- hideError('add-error');
+ $("btn-add-back").addEventListener("click", () => {
+ showView("vault");
+ hideError("add-error");
});
// Show/hide password toggle.
- $('btn-add-toggle-pw').addEventListener('click', () => {
- const pw = $('add-password');
- pw.type = pw.type === 'password' ? 'text' : 'password';
+ $("btn-add-toggle-pw").addEventListener("click", () => {
+ const pw = $("add-password");
+ pw.type = pw.type === "password" ? "text" : "password";
});
// Generate password — generates via the generator logic and pastes into field.
- $('btn-add-generate').addEventListener('click', () => {
+ $("btn-add-generate").addEventListener("click", () => {
const pw = generatePassword(16, true, true, true, true);
- $('add-password').value = pw;
- $('add-password').type = 'text'; // show it so the user can see what was generated
+ $("add-password").value = pw;
+ $("add-password").type = "text"; // show it so the user can see what was generated
});
// Save button.
- $('btn-add-save').addEventListener('click', addItemToVault);
+ $("btn-add-save").addEventListener("click", addItemToVault);
// Allow Enter in any text field to submit.
- ['add-name', 'add-url', 'add-username', 'add-password'].forEach(id => {
- $(id).addEventListener('keydown', e => { if (e.key === 'Enter') addItemToVault(); });
+ ["add-name", "add-url", "add-username", "add-password"].forEach((id) => {
+ $(id).addEventListener("keydown", (e) => {
+ if (e.key === "Enter") addItemToVault();
+ });
});
}
// Clear fields and error each time the view opens.
- ['add-name', 'add-url', 'add-username', 'add-password', 'add-notes'].forEach(id => {
- // Don't reset url/name — already pre-filled above.
- if (id === 'add-url' || id === 'add-name') return;
- $(id).value = '';
- });
- $('add-password').type = 'password';
- hideError('add-error');
- $('add-name').focus();
+ ["add-name", "add-url", "add-username", "add-password", "add-notes"].forEach(
+ (id) => {
+ // Don't reset url/name — already pre-filled above.
+ if (id === "add-url" || id === "add-name") return;
+ $(id).value = "";
+ },
+ );
+ $("add-password").type = "password";
+ hideError("add-error");
+ $("add-name").focus();
}
async function addItemToVault() {
- const name = $('add-name').value.trim();
- const url = $('add-url').value.trim();
- const username = $('add-username').value.trim();
- const password = $('add-password').value;
- const notes = $('add-notes').value.trim();
- const folderVal = $('add-folder').value;
+ const name = $("add-name").value.trim();
+ const url = $("add-url").value.trim();
+ const username = $("add-username").value.trim();
+ const password = $("add-password").value;
+ const notes = $("add-notes").value.trim();
+ const folderVal = $("add-folder").value;
const folder_id = folderVal ? parseInt(folderVal, 10) : null;
- hideError('add-error');
+ hideError("add-error");
- if (!name) { showError('add-error', 'Site name is required.'); return; }
- if (!password) { showError('add-error', 'Password is required.'); return; }
- if (!_vaultKey) { showError('add-error', 'Vault is locked. Please unlock first.'); return; }
+ if (!name) {
+ showError("add-error", "Site name is required.");
+ return;
+ }
+ if (!password) {
+ showError("add-error", "Password is required.");
+ return;
+ }
+ if (!_vaultKey) {
+ showError("add-error", "Vault is locked. Please unlock first.");
+ return;
+ }
- const btn = $('btn-add-save');
+ const btn = $("btn-add-save");
btn.disabled = true;
- btn.textContent = 'Saving…';
+ btn.textContent = "Saving…";
try {
const plain = { url, username, password, notes };
const { enc_data, iv } = await ExtCrypto.encryptItem(_vaultKey, plain);
const { enc_name, iv_name } = await ExtCrypto.encryptName(_vaultKey, name);
- const res = await apiFetch('/api/vault', {
- method: 'POST',
- body: JSON.stringify({ name: 'password', item_type: 'password', folder_id, enc_data, iv, enc_name, iv_name }),
+ const res = await apiFetch("/api/vault", {
+ method: "POST",
+ body: JSON.stringify({
+ name: "password",
+ item_type: "password",
+ folder_id,
+ enc_data,
+ iv,
+ enc_name,
+ iv_name,
+ }),
});
if (!res?.ok) {
const data = await res.json().catch(() => ({}));
- showError('add-error', data.error || 'Failed to save. Please try again.');
+ showError("add-error", data.error || "Failed to save. Please try again.");
return;
}
- console.log('[PassKeeper] Item added to vault:', name, '(name encrypted)');
+ console.log("[PassKeeper] Item added to vault:", name, "(name encrypted)");
// Refresh vault and go back.
await fetchAndDecryptVault();
renderList();
- showView('vault');
+ showView("vault");
} catch (err) {
- showError('add-error', 'Network error: ' + err.message);
+ showError("add-error", "Network error: " + err.message);
} finally {
btn.disabled = false;
- btn.textContent = 'Save to Vault';
+ btn.textContent = "Save to Vault";
}
}
// ── Account view ──────────────────────────────────────────────────────────────
-const IDLE_TIMEOUT_KEY = 'idle_lock_seconds';
+const IDLE_TIMEOUT_KEY = "idle_lock_seconds";
async function initAccountView() {
// Load saved timeout value and reflect it in the select.
const stored = await chrome.storage.local.get(IDLE_TIMEOUT_KEY);
const saved = stored[IDLE_TIMEOUT_KEY];
if (saved !== undefined) {
- const sel = $('acct-idle-timeout');
+ const sel = $("acct-idle-timeout");
// Find the matching option; fall back to 600 if not found.
- const opt = Array.from(sel.options).find(o => parseInt(o.value) === saved);
+ const opt = Array.from(sel.options).find(
+ (o) => parseInt(o.value) === saved,
+ );
if (opt) sel.value = String(saved);
}
// Save on change and notify the background to apply immediately.
- $('acct-idle-timeout').addEventListener('change', async () => {
- const val = parseInt($('acct-idle-timeout').value);
+ $("acct-idle-timeout").addEventListener("change", async () => {
+ const val = parseInt($("acct-idle-timeout").value);
await chrome.storage.local.set({ [IDLE_TIMEOUT_KEY]: val });
// Tell background to re-apply the new interval.
- chrome.runtime.sendMessage({ type: 'SET_IDLE_TIMEOUT', seconds: val }).catch(() => { });
+ chrome.runtime
+ .sendMessage({ type: "SET_IDLE_TIMEOUT", seconds: val })
+ .catch(() => {});
// Brief "Saved" confirmation.
- const saved = $('acct-idle-saved');
- saved.classList.remove('hidden');
- setTimeout(() => saved.classList.add('hidden'), 2000);
- console.log('[PassKeeper] Idle lock timeout set to', val, 'seconds');
+ const saved = $("acct-idle-saved");
+ saved.classList.remove("hidden");
+ setTimeout(() => saved.classList.add("hidden"), 2000);
+ console.log("[PassKeeper] Idle lock timeout set to", val, "seconds");
});
- $('acct-open-vault').href = VAULT_URL;
- $('acct-signout').addEventListener('click', signOut);
+ $("acct-open-vault").href = VAULT_URL;
+ $("acct-signout").addEventListener("click", signOut);
}
// ── Init ──────────────────────────────────────────────────────────────────────
async function init() {
try {
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
- _currentUrl = tab?.url || '';
- } catch { }
+ const [tab] = await chrome.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ _currentUrl = tab?.url || "";
+ } catch {}
// Set vault link
- $('btn-open-vault').href = VAULT_URL;
+ $("btn-open-vault").href = VAULT_URL;
const sessionState = await restoreSessionIfAvailable();
- if (sessionState === 'vault') {
+ if (sessionState === "vault") {
// Check whether the content script requested a specific view (e.g. generator).
- const { popup_nav } = await chrome.storage.session.get('popup_nav');
+ const { popup_nav } = await chrome.storage.session.get("popup_nav");
if (popup_nav) {
- await chrome.storage.session.remove('popup_nav');
- if (popup_nav === 'generator') {
- showView('generator');
+ await chrome.storage.session.remove("popup_nav");
+ if (popup_nav === "generator") {
+ showView("generator");
initGenerator();
// Still load vault data in background so the Vault tab is ready.
- fetchAndDecryptVault().then(() => { });
+ fetchAndDecryptVault().then(() => {});
// Wire event listeners below, then return early from vault-specific setup.
} else {
- showView('vault');
+ showView("vault");
await checkPendingSave();
if (_items.length) {
renderList();
@@ -1153,7 +1411,7 @@ async function init() {
}
}
} else {
- showView('vault');
+ showView("vault");
await checkPendingSave();
if (_items.length) {
renderList();
@@ -1163,46 +1421,70 @@ async function init() {
renderList();
}
}
- } else if (sessionState === 'unlock') {
- showView('unlock');
- $('unlock-password').focus();
+ } else if (sessionState === "unlock") {
+ showView("unlock");
+ $("unlock-password").focus();
} else {
- showView('login');
+ 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(); });
+ $("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(); });
+ $("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);
+ $("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', () => { showView('add'); initAddView(); });
- $('nav-vault').addEventListener('click', () => { showView('vault'); });
- $('nav-generator').addEventListener('click', () => { showView('generator'); initGenerator(); });
- $('nav-alerts').addEventListener('click', () => { chrome.tabs.create({ url: ALERTS_URL }); });
- $('nav-account').addEventListener('click', () => { showView('account'); initAccountView(); });
+ $("btn-add-item").addEventListener("click", () => {
+ showView("add");
+ initAddView();
+ });
+ $("nav-vault").addEventListener("click", () => {
+ showView("vault");
+ });
+ $("nav-generator").addEventListener("click", () => {
+ showView("generator");
+ initGenerator();
+ });
+ $("nav-alerts").addEventListener("click", () => {
+ chrome.tabs.create({ url: ALERTS_URL });
+ });
+ $("nav-account").addEventListener("click", () => {
+ showView("account");
+ initAccountView();
+ });
// 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(() => { });
+ chrome.runtime.sendMessage({ type: "KEEPALIVE" }).catch(() => {});
}, 20_000);
// Search
- $('vault-search').addEventListener('input', () => renderList());
+ $("vault-search").addEventListener("input", () => renderList());
// Tabs
initTabs();
}
-document.addEventListener('DOMContentLoaded', init);
\ No newline at end of file
+document.addEventListener("DOMContentLoaded", init);