diff --git a/extension/background.js b/extension/background.js index ebb6a7a..4b643e2 100644 --- a/extension/background.js +++ b/extension/background.js @@ -12,37 +12,49 @@ async function updateBadgeForTab(tabId, url) { try { - const { vault_items } = await chrome.storage.session.get('vault_items'); + const { vault_items } = await chrome.storage.session.get("vault_items"); if (!vault_items?.length) { - chrome.action.setBadgeText({ text: '', tabId }); + chrome.action.setBadgeText({ text: "", tabId }); return; } let hostname; - try { hostname = new URL(url).hostname.replace(/^www\./, ''); } - catch { chrome.action.setBadgeText({ text: '', tabId }); return; } + try { + hostname = new URL(url).hostname.replace(/^www\./, ""); + } catch { + chrome.action.setBadgeText({ text: "", tabId }); + return; + } - const matches = vault_items.filter(item => { - if (item.item_type !== 'password' || !item.plain?.url) return false; + const matches = vault_items.filter((item) => { + if (item.item_type !== "password" || !item.plain?.url) return false; try { - const h = new URL(item.plain.url).hostname.replace(/^www\./, ''); - return h === hostname || h.endsWith(`.${hostname}`) || hostname.endsWith(`.${h}`); - } catch { return false; } + const h = new URL(item.plain.url).hostname.replace(/^www\./, ""); + return ( + h === hostname || + h.endsWith(`.${hostname}`) || + hostname.endsWith(`.${h}`) + ); + } catch { + return false; + } }); if (matches.length > 0) { chrome.action.setBadgeText({ text: String(matches.length), tabId }); - chrome.action.setBadgeBackgroundColor({ color: '#1a73e8', tabId }); + chrome.action.setBadgeBackgroundColor({ color: "#1a73e8", tabId }); } else { - chrome.action.setBadgeText({ text: '', tabId }); + chrome.action.setBadgeText({ text: "", tabId }); } - } catch { /* tab may have closed */ } + } catch { + /* tab may have closed */ + } } async function refreshAllBadges() { const tabs = await chrome.tabs.query({}); for (const tab of tabs) { - if (tab.url?.startsWith('http')) updateBadgeForTab(tab.id, tab.url); + if (tab.url?.startsWith("http")) updateBadgeForTab(tab.id, tab.url); } } @@ -51,12 +63,12 @@ async function refreshAllBadges() { chrome.tabs.onActivated.addListener(async ({ tabId }) => { try { const tab = await chrome.tabs.get(tabId); - if (tab.url?.startsWith('http')) updateBadgeForTab(tabId, tab.url); + if (tab.url?.startsWith("http")) updateBadgeForTab(tabId, tab.url); } catch {} }); chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { - if (changeInfo.status === 'complete' && tab.url?.startsWith('http')) { + if (changeInfo.status === "complete" && tab.url?.startsWith("http")) { updateBadgeForTab(tabId, tab.url); } }); @@ -64,33 +76,32 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { // ── Message handler ────────────────────────────────────────────────────────── chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { - // No-op ping from popup — keeps the service worker alive while the browser // is open so chrome.storage.session is not wiped between popup openings. - if (msg.type === 'KEEPALIVE') { + if (msg.type === "KEEPALIVE") { sendResponse({ ok: true }); return false; } // Popup signals vault cache was refreshed → re-check all tab badges. - if (msg.type === 'VAULT_UPDATED') { + if (msg.type === "VAULT_UPDATED") { refreshAllBadges(); sendResponse({ ok: true }); return false; } // Content script detected credentials on form submit → store for save-prompt. - if (msg.type === 'SAVE_CREDENTIALS') { + if (msg.type === "SAVE_CREDENTIALS") { chrome.storage.session.set({ pending_save: msg.data }); sendResponse({ ok: true }); return false; } // Bridge: web app logged in → store tokens in extension storage. - if (msg.type === 'WEB_SESSION_SYNC') { + if (msg.type === "WEB_SESSION_SYNC") { chrome.storage.session.set({ access_token: msg.access_token, - enc_key_salt: msg.enc_key_salt || '', + enc_key_salt: msg.enc_key_salt || "", }); // Also persist enc_key_salt locally so the unlock-only view survives browser restarts chrome.storage.local.set({ @@ -102,25 +113,35 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { } // Bridge: web app logged out → clear extension session. - if (msg.type === 'WEB_SESSION_CLEAR') { - chrome.storage.session.remove(['access_token', 'vault_key_jwk', 'vault_items', 'enc_key_salt']); - chrome.storage.local.remove('refresh_token'); + if (msg.type === "WEB_SESSION_CLEAR") { + chrome.storage.session.remove([ + "access_token", + "vault_key_jwk", + "vault_items", + "enc_key_salt", + ]); + chrome.storage.local.remove("refresh_token"); sendResponse({ ok: true }); return false; } // Popup logged in via extension → inject session into open vault tabs. - if (msg.type === 'EXT_SESSION_SYNC') { - chrome.tabs.query({ url: 'https://pwkeeper.ngodanguyen.tech/*' }, tabs => { - tabs.forEach(tab => { - chrome.tabs.sendMessage(tab.id, { - type: 'INJECT_SESSION', - access_token: msg.access_token, - refresh_token: msg.refresh_token, - enc_key_salt: msg.enc_key_salt, - }).catch(() => {}); - }); - }); + if (msg.type === "EXT_SESSION_SYNC") { + chrome.tabs.query( + { url: "https://pwkeeper.ngodanguyen.tech/*" }, + (tabs) => { + tabs.forEach((tab) => { + chrome.tabs + .sendMessage(tab.id, { + type: "INJECT_SESSION", + access_token: msg.access_token, + refresh_token: msg.refresh_token, + enc_key_salt: msg.enc_key_salt, + }) + .catch(() => {}); + }); + }, + ); sendResponse({ ok: true }); return false; } diff --git a/extension/content/content.js b/extension/content/content.js index f10dc19..5285407 100644 --- a/extension/content/content.js +++ b/extension/content/content.js @@ -8,33 +8,37 @@ * 4. Watches form submissions → shows save-credentials banner. */ (() => { - 'use strict'; + "use strict"; - const PK_ATTR = 'data-pk-decorated'; - const PK_BTN_CLASS = '__pk_autofill_btn__'; + const PK_ATTR = "data-pk-decorated"; + const PK_BTN_CLASS = "__pk_autofill_btn__"; - let _bannerEl = null; + let _bannerEl = null; let _hasNotifiedForm = false; - let _formObserver = null; + let _formObserver = null; // ── Helpers ────────────────────────────────────────────────────────────────── function escHtml(str) { - return String(str ?? '').replace(/&/g,'&').replace(//g,'>'); + return String(str ?? "") + .replace(/&/g, "&") + .replace(//g, ">"); } function visiblePasswordFields() { - return Array.from(document.querySelectorAll('input[type="password"]')) - .filter(el => el.offsetParent !== null && !el.disabled); + return Array.from( + document.querySelectorAll('input[type="password"]'), + ).filter((el) => el.offsetParent !== null && !el.disabled); } function findUsernameField(pwField) { - const all = Array.from(document.querySelectorAll('input')); + const all = Array.from(document.querySelectorAll("input")); const idx = all.indexOf(pwField); for (let i = idx - 1; i >= 0; i--) { const el = all[i]; if (!el.offsetParent || el.disabled) continue; - if (['email', 'text', 'tel'].includes(el.type)) return el; + if (["email", "text", "tel"].includes(el.type)) return el; } return null; } @@ -42,24 +46,29 @@ // ── Framework-compatible fill ───────────────────────────────────────────────── function fillField(el, value) { - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; if (setter) setter.call(el, value); else el.value = value; - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); } function doAutofill(username, password) { const pwFields = visiblePasswordFields(); if (!pwFields.length) return; - const pwField = pwFields[0]; + const pwField = pwFields[0]; const usernameField = findUsernameField(pwField); if (usernameField && username) fillField(usernameField, username); if (password) fillField(pwField, password); - [usernameField, pwField].filter(Boolean).forEach(el => { - el.style.outline = '2px solid #1a73e8'; - setTimeout(() => { el.style.outline = ''; }, 1500); + [usernameField, pwField].filter(Boolean).forEach((el) => { + el.style.outline = "2px solid #1a73e8"; + setTimeout(() => { + el.style.outline = ""; + }, 1500); }); } @@ -70,39 +79,39 @@ * password field. Clicking it opens a tiny dropdown listing matching items. */ function injectAutofillButtons(matchingItems) { - visiblePasswordFields().forEach(pwField => { + visiblePasswordFields().forEach((pwField) => { if (pwField.getAttribute(PK_ATTR)) return; // already decorated - pwField.setAttribute(PK_ATTR, '1'); + pwField.setAttribute(PK_ATTR, "1"); // Wrap the field if it isn't already positioned - const wrap = document.createElement('div'); - wrap.style.cssText = 'position:relative;display:inline-block;width:100%;'; + const wrap = document.createElement("div"); + wrap.style.cssText = "position:relative;display:inline-block;width:100%;"; pwField.parentNode.insertBefore(wrap, pwField); wrap.appendChild(pwField); // Add right-side padding so text doesn't overlap the button - pwField.style.paddingRight = '32px'; + pwField.style.paddingRight = "32px"; // The icon button - const btn = document.createElement('button'); - btn.type = 'button'; + const btn = document.createElement("button"); + btn.type = "button"; btn.className = PK_BTN_CLASS; - btn.title = 'Autofill with PassKeeper'; - btn.setAttribute('aria-label', 'Autofill with PassKeeper'); + btn.title = "Autofill with PassKeeper"; + btn.setAttribute("aria-label", "Autofill with PassKeeper"); btn.style.cssText = [ - 'position:absolute', - 'right:6px', - 'top:50%', - 'transform:translateY(-50%)', - 'background:none', - 'border:none', - 'cursor:pointer', - 'padding:3px', - 'display:flex', - 'align-items:center', - 'justify-content:center', - 'z-index:2147483646', - ].join(';'); + "position:absolute", + "right:6px", + "top:50%", + "transform:translateY(-50%)", + "background:none", + "border:none", + "cursor:pointer", + "padding:3px", + "display:flex", + "align-items:center", + "justify-content:center", + "z-index:2147483646", + ].join(";"); btn.innerHTML = ` @@ -112,7 +121,7 @@ wrap.appendChild(btn); - btn.addEventListener('click', e => { + btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); toggleDropdown(btn, pwField, matchingItems); @@ -123,62 +132,73 @@ // Dropdown showing matching credentials function toggleDropdown(btn, pwField, items) { // Remove any existing dropdown - const existing = document.getElementById('__pk_dropdown__'); - if (existing) { existing.remove(); return; } + const existing = document.getElementById("__pk_dropdown__"); + if (existing) { + existing.remove(); + return; + } const usernameField = findUsernameField(pwField); - const dropdown = document.createElement('div'); - dropdown.id = '__pk_dropdown__'; + const dropdown = document.createElement("div"); + dropdown.id = "__pk_dropdown__"; dropdown.style.cssText = [ - 'position:fixed', - 'background:#fff', - 'border:1px solid #e2e8f0', - 'border-radius:10px', - 'box-shadow:0 8px 30px rgba(0,0,0,0.15)', - 'z-index:2147483647', - 'min-width:240px', - 'max-width:300px', + "position:fixed", + "background:#fff", + "border:1px solid #e2e8f0", + "border-radius:10px", + "box-shadow:0 8px 30px rgba(0,0,0,0.15)", + "z-index:2147483647", + "min-width:240px", + "max-width:300px", 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif', - 'font-size:13px', - 'overflow:hidden', - ].join(';'); + "font-size:13px", + "overflow:hidden", + ].join(";"); // Position below the button const rect = btn.getBoundingClientRect(); - dropdown.style.top = (rect.bottom + 6) + 'px'; - dropdown.style.left = Math.max(8, rect.right - 260) + 'px'; + dropdown.style.top = rect.bottom + 6 + "px"; + dropdown.style.left = Math.max(8, rect.right - 260) + "px"; // Header - const header = document.createElement('div'); - header.style.cssText = 'padding:9px 12px 8px;border-bottom:1px solid #f3f4f6;display:flex;align-items:center;gap:6px;'; + const header = document.createElement("div"); + header.style.cssText = + "padding:9px 12px 8px;border-bottom:1px solid #f3f4f6;display:flex;align-items:center;gap:6px;"; header.innerHTML = `PassKeeper`; dropdown.appendChild(header); if (!items.length) { - const empty = document.createElement('div'); - empty.style.cssText = 'padding:14px 12px;color:#9ca3af;font-size:12px;text-align:center;'; - empty.textContent = 'No matching credentials'; + const empty = document.createElement("div"); + empty.style.cssText = + "padding:14px 12px;color:#9ca3af;font-size:12px;text-align:center;"; + empty.textContent = "No matching credentials"; dropdown.appendChild(empty); } else { - items.forEach(item => { - const row = document.createElement('div'); - row.style.cssText = 'display:flex;align-items:center;gap:9px;padding:9px 12px;cursor:pointer;transition:background 0.1s;'; - row.onmouseenter = () => { row.style.background = '#f9fafb'; }; - row.onmouseleave = () => { row.style.background = ''; }; + items.forEach((item) => { + const row = document.createElement("div"); + row.style.cssText = + "display:flex;align-items:center;gap:9px;padding:9px 12px;cursor:pointer;transition:background 0.1s;"; + row.onmouseenter = () => { + row.style.background = "#f9fafb"; + }; + row.onmouseleave = () => { + row.style.background = ""; + }; - const username = escHtml(item.plain?.username || ''); - const name = escHtml(item.name); + const username = escHtml(item.plain?.username || ""); + const name = escHtml(item.name); row.innerHTML = `
🔑
${name}
- ${username ? `
${username}
` : ''} + ${username ? `
${username}
` : ""}
`; - row.addEventListener('click', () => { - if (usernameField && item.plain?.username) fillField(usernameField, item.plain.username); + row.addEventListener("click", () => { + if (usernameField && item.plain?.username) + fillField(usernameField, item.plain.username); if (item.plain?.password) fillField(pwField, item.plain.password); dropdown.remove(); }); @@ -190,13 +210,13 @@ document.body.appendChild(dropdown); // Close on outside click - const close = e => { + const close = (e) => { if (!dropdown.contains(e.target) && e.target !== btn) { dropdown.remove(); - document.removeEventListener('click', close, true); + document.removeEventListener("click", close, true); } }; - setTimeout(() => document.addEventListener('click', close, true), 0); + setTimeout(() => document.addEventListener("click", close, true), 0); } // ── Form detection ──────────────────────────────────────────────────────────── @@ -205,23 +225,27 @@ if (_hasNotifiedForm) return; if (!visiblePasswordFields().length) return; _hasNotifiedForm = true; - chrome.runtime.sendMessage({ type: 'FORMS_DETECTED' }).catch(() => {}); + chrome.runtime.sendMessage({ type: "FORMS_DETECTED" }).catch(() => {}); } async function decorateFields() { if (!visiblePasswordFields().length) return; // Get cached vault items from session storage - const { vault_items } = await chrome.storage.session.get('vault_items').catch(() => ({})); + const { vault_items } = await chrome.storage.session + .get("vault_items") + .catch(() => ({})); if (!vault_items?.length) return; - const host = location.hostname.replace(/^www\./, ''); - const matching = vault_items.filter(item => { - if (item.item_type !== 'password' || !item.plain?.url) return false; + const host = location.hostname.replace(/^www\./, ""); + const matching = vault_items.filter((item) => { + if (item.item_type !== "password" || !item.plain?.url) return false; try { - const h = new URL(item.plain.url).hostname.replace(/^www\./, ''); + const h = new URL(item.plain.url).hostname.replace(/^www\./, ""); return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`); - } catch { return false; } + } catch { + return false; + } }); injectAutofillButtons(matching); @@ -241,33 +265,36 @@ async function classifyCredentials(username, password) { let vault_items; try { - ({ vault_items } = await chrome.storage.session.get('vault_items')); + ({ vault_items } = await chrome.storage.session.get("vault_items")); } catch { // If we can't read storage (e.g. extension context invalidated), show banner. - return 'new'; + return "new"; } - if (!vault_items?.length) return 'new'; + if (!vault_items?.length) return "new"; - const host = location.hostname.replace(/^www\./, ''); + const host = location.hostname.replace(/^www\./, ""); - const siteItems = vault_items.filter(item => { - if (item.item_type !== 'password' || !item.plain?.url) return false; + const siteItems = vault_items.filter((item) => { + if (item.item_type !== "password" || !item.plain?.url) return false; try { - const h = new URL(item.plain.url).hostname.replace(/^www\./, ''); + const h = new URL(item.plain.url).hostname.replace(/^www\./, ""); return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`); - } catch { return false; } + } catch { + return false; + } }); - if (!siteItems.length) return 'new'; + if (!siteItems.length) return "new"; // Check for exact match (same username AND same password). const exactMatch = siteItems.some( - item => item.plain?.username === username && item.plain?.password === password + (item) => + item.plain?.username === username && item.plain?.password === password, ); - if (exactMatch) return 'same'; + if (exactMatch) return "same"; // Credentials differ → treat as updated. - return 'updated'; + return "updated"; } // ── Auto-save banner ────────────────────────────────────────────────────────── @@ -283,30 +310,32 @@ function showSaveBanner(username, password, credentialState) { if (_bannerEl) _bannerEl.remove(); - const banner = document.createElement('div'); - banner.id = '__pk_save_banner__'; + const banner = document.createElement("div"); + banner.id = "__pk_save_banner__"; Object.assign(banner.style, { - position: 'fixed', - top: '12px', - right: '12px', - zIndex: '2147483647', - background: '#ffffff', - border: '1px solid #e2e8f0', - borderRadius: '10px', - boxShadow: '0 8px 30px rgba(0,0,0,0.15)', - padding: '14px 16px 12px', - fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", - fontSize: '13px', - color: '#1a1a2e', - maxWidth: '300px', - minWidth: '240px', + position: "fixed", + top: "12px", + right: "12px", + zIndex: "2147483647", + background: "#ffffff", + border: "1px solid #e2e8f0", + borderRadius: "10px", + boxShadow: "0 8px 30px rgba(0,0,0,0.15)", + padding: "14px 16px 12px", + fontFamily: + "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", + fontSize: "13px", + color: "#1a1a2e", + maxWidth: "300px", + minWidth: "240px", }); - const site = escHtml(location.hostname); - const user = escHtml(username); - const title = credentialState === 'updated' - ? 'Update in PassKeeper?' - : 'Save to PassKeeper?'; + const site = escHtml(location.hostname); + const user = escHtml(username); + const title = + credentialState === "updated" + ? "Update in PassKeeper?" + : "Save to PassKeeper?"; banner.innerHTML = `
@@ -326,15 +355,30 @@ _bannerEl = banner; // No setTimeout — banner stays until the user makes an explicit choice. - const dismiss = () => { if (_bannerEl === banner) { banner.remove(); _bannerEl = null; } }; - banner.querySelector('#__pk_close__').addEventListener('click', dismiss); - banner.querySelector('#__pk_skip__').addEventListener('click', dismiss); - banner.querySelector('#__pk_save__').addEventListener('click', () => { - console.log('[PassKeeper] User chose to save credentials for', location.hostname); - chrome.runtime.sendMessage({ - type: 'SAVE_CREDENTIALS', - data: { url: location.href, siteName: document.title || location.hostname, username, password }, - }).catch(() => {}); + const dismiss = () => { + if (_bannerEl === banner) { + banner.remove(); + _bannerEl = null; + } + }; + banner.querySelector("#__pk_close__").addEventListener("click", dismiss); + banner.querySelector("#__pk_skip__").addEventListener("click", dismiss); + banner.querySelector("#__pk_save__").addEventListener("click", () => { + console.log( + "[PassKeeper] User chose to save credentials for", + location.hostname, + ); + chrome.runtime + .sendMessage({ + type: "SAVE_CREDENTIALS", + data: { + url: location.href, + siteName: document.title || location.hostname, + username, + password, + }, + }) + .catch(() => {}); dismiss(); }); } @@ -342,44 +386,63 @@ // ── Form submission watch ───────────────────────────────────────────────────── function watchSubmissions() { - document.addEventListener('submit', async e => { - const form = e.target; - const pwField = form.querySelector('input[type="password"]:not([disabled])'); - if (!pwField?.value) return; + document.addEventListener( + "submit", + async (e) => { + const form = e.target; + const pwField = form.querySelector( + 'input[type="password"]:not([disabled])', + ); + if (!pwField?.value) return; - const userField = findUsernameField(pwField) - ?? form.querySelector('input[type="email"]:not([disabled])') - ?? form.querySelector('input[type="text"]:not([disabled])'); + const userField = + findUsernameField(pwField) ?? + form.querySelector('input[type="email"]:not([disabled])') ?? + form.querySelector('input[type="text"]:not([disabled])'); - const username = userField?.value?.trim() || ''; - const password = pwField.value; + const username = userField?.value?.trim() || ""; + const password = pwField.value; - if (!username || !password) return; + if (!username || !password) return; - // Run duplicate check before showing the banner. - const credentialState = await classifyCredentials(username, password); - console.log('[PassKeeper] Credential state for', location.hostname, '→', credentialState); + // Run duplicate check before showing the banner. + const credentialState = await classifyCredentials(username, password); + console.log( + "[PassKeeper] Credential state for", + location.hostname, + "→", + credentialState, + ); - if (credentialState === 'same') { - // Credentials unchanged — silently skip. - return; - } + if (credentialState === "same") { + // Credentials unchanged — silently skip. + return; + } - setTimeout(() => showSaveBanner(username, password, credentialState), 500); - }, true); + setTimeout( + () => showSaveBanner(username, password, credentialState), + 500, + ); + }, + true, + ); } // ── Message listener ────────────────────────────────────────────────────────── chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg.type === 'DO_AUTOFILL') { + if (msg.type === "DO_AUTOFILL") { doAutofill(msg.username, msg.password); sendResponse({ ok: true }); } - if (msg.type === 'VAULT_UPDATED') { + if (msg.type === "VAULT_UPDATED") { // Re-decorate fields with fresh data - document.querySelectorAll(`[${PK_ATTR}]`).forEach(el => el.removeAttribute(PK_ATTR)); - document.querySelectorAll(`.${PK_BTN_CLASS}`).forEach(el => el.remove()); + document + .querySelectorAll(`[${PK_ATTR}]`) + .forEach((el) => el.removeAttribute(PK_ATTR)); + document + .querySelectorAll(`.${PK_BTN_CLASS}`) + .forEach((el) => el.remove()); decorateFields(); } return false; @@ -400,8 +463,8 @@ _formObserver.observe(document.body, { childList: true, subtree: true }); } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); } else { init(); } diff --git a/extension/popup/popup.css b/extension/popup/popup.css index f99c100..96ccad4 100644 --- a/extension/popup/popup.css +++ b/extension/popup/popup.css @@ -1,17 +1,30 @@ /* PassKeeper Extension Popup */ -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} body { width: 320px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 13px; color: #1a1a2e; background: #fff; overflow: hidden; } -.view { display: flex; flex-direction: column; min-height: 0; } -.view.hidden { display: none; } +.view { + display: flex; + flex-direction: column; + min-height: 0; +} +.view.hidden { + display: none; +} /* ── Login ───────────────────────────────────────────────────────── */ .login-header { @@ -22,42 +35,99 @@ body { background: linear-gradient(135deg, #c0392b, #922b21); color: #fff; } -.login-logo { font-size: 36px; margin-bottom: 8px; } -.login-header h1 { font-size: 18px; font-weight: 600; letter-spacing: 0.3px; } - -.login-body { padding: 18px 20px 20px; } -.pk-hint { font-size: 12px; color: #666; margin-bottom: 12px; text-align: center; } - -.form-group { margin-bottom: 11px; } -.form-group label { display: block; font-size: 11px; color: #555; font-weight: 500; margin-bottom: 3px; } -.form-group input { - width: 100%; padding: 8px 11px; - border: 1px solid #ddd; border-radius: 6px; - font-size: 13px; outline: none; background: #fff; color: #1a1a2e; - transition: border-color 0.15s, box-shadow 0.15s; +.login-logo { + font-size: 36px; + margin-bottom: 8px; +} +.login-header h1 { + font-size: 18px; + font-weight: 600; + letter-spacing: 0.3px; +} + +.login-body { + padding: 18px 20px 20px; +} +.pk-hint { + font-size: 12px; + color: #666; + margin-bottom: 12px; + text-align: center; +} + +.form-group { + margin-bottom: 11px; +} +.form-group label { + display: block; + font-size: 11px; + color: #555; + font-weight: 500; + margin-bottom: 3px; +} +.form-group input { + width: 100%; + padding: 8px 11px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 13px; + outline: none; + background: #fff; + color: #1a1a2e; + transition: + border-color 0.15s, + box-shadow 0.15s; +} +.form-group input:focus { + border-color: #c0392b; + box-shadow: 0 0 0 2px rgba(192, 57, 43, 0.12); } -.form-group input:focus { border-color: #c0392b; box-shadow: 0 0 0 2px rgba(192,57,43,0.12); } /* ── Buttons ─────────────────────────────────────────────────────── */ .btn-primary { - display: block; width: 100%; padding: 9px; - background: #c0392b; color: #fff; - border: none; border-radius: 6px; - font-size: 13px; font-weight: 600; cursor: pointer; - margin-top: 10px; transition: background 0.15s; + display: block; + width: 100%; + padding: 9px; + background: #c0392b; + color: #fff; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + margin-top: 10px; + transition: background 0.15s; +} +.btn-primary:hover { + background: #a93226; +} +.btn-primary:disabled { + background: #e8a49e; + cursor: default; +} +.btn-sm { + width: auto; + padding: 6px 16px; + margin-top: 8px; } -.btn-primary:hover { background: #a93226; } -.btn-primary:disabled { background: #e8a49e; cursor: default; } -.btn-sm { width: auto; padding: 6px 16px; margin-top: 8px; } .btn-ghost { - display: block; width: 100%; padding: 9px; - background: transparent; color: #c0392b; - border: 1px solid #e5c0bb; border-radius: 6px; - font-size: 13px; font-weight: 500; cursor: pointer; - margin-top: 6px; transition: background 0.15s; + display: block; + width: 100%; + padding: 9px; + background: transparent; + color: #c0392b; + border: 1px solid #e5c0bb; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + margin-top: 6px; + transition: background 0.15s; +} +.btn-ghost:hover { + background: #fdf2f1; } -.btn-ghost:hover { background: #fdf2f1; } /* ── Vault top bar ───────────────────────────────────────────────── */ .vault-topbar { @@ -78,54 +148,62 @@ body { padding: 6px 10px; gap: 7px; } -.search-icon { flex-shrink: 0; opacity: 0.6; } -#vault-search { - flex: 1; border: none; background: transparent; - font-size: 13px; outline: none; color: #1a1a2e; +.search-icon { + flex-shrink: 0; + opacity: 0.6; +} +#vault-search { + flex: 1; + border: none; + background: transparent; + font-size: 13px; + outline: none; + color: #1a1a2e; +} +#vault-search::placeholder { + color: #9ca3af; } -#vault-search::placeholder { color: #9ca3af; } .btn-vault-link { - display: inline-flex; align-items: center; gap: 5px; + display: inline-flex; + align-items: center; + gap: 5px; padding: 6px 10px; - background: #1a1a2e; color: #fff; - border: none; border-radius: 7px; - font-size: 12px; font-weight: 600; cursor: pointer; + background: #1a1a2e; + color: #fff; + border: none; + border-radius: 7px; + font-size: 12px; + font-weight: 600; + cursor: pointer; text-decoration: none; white-space: nowrap; transition: background 0.15s; flex-shrink: 0; } -.btn-vault-link:hover { background: #2d2d4e; } +.btn-vault-link:hover { + background: #2d2d4e; +} .btn-add { - width: 32px; height: 32px; - background: #f3f4f6; color: #1a1a2e; - border: none; border-radius: 7px; - font-size: 20px; line-height: 1; cursor: pointer; - display: flex; align-items: center; justify-content: center; + width: 32px; + height: 32px; + background: #f3f4f6; + color: #1a1a2e; + border: none; + border-radius: 7px; + font-size: 20px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; flex-shrink: 0; transition: background 0.15s; } -.btn-add:hover { background: #e5e7eb; } - -/* ── Save prompt ─────────────────────────────────────────────────── */ -.save-prompt { - background: #fef9f0; - border-bottom: 2px solid #f59e0b; - padding: 10px 14px 12px; +.btn-add:hover { + background: #e5e7eb; } -.save-prompt.hidden { display: none; } -.save-prompt-title { - display: flex; align-items: center; gap: 6px; - font-size: 12px; font-weight: 600; color: #92400e; - margin-bottom: 8px; -} -.save-dismiss { - margin-left: auto; background: none; border: none; - cursor: pointer; color: #92400e; font-size: 14px; line-height: 1; -} -.save-prompt .form-group { margin-bottom: 6px; } /* ── Tabs ────────────────────────────────────────────────────────── */ .vault-tabs { @@ -137,20 +215,33 @@ body { overflow-x: auto; scrollbar-width: none; } -.vault-tabs::-webkit-scrollbar { display: none; } +.vault-tabs::-webkit-scrollbar { + display: none; +} .tab-btn { padding: 6px 12px 8px; - background: none; border: none; - font-size: 12px; font-weight: 500; - color: #6b7280; cursor: pointer; + background: none; + border: none; + font-size: 12px; + font-weight: 500; + color: #6b7280; + cursor: pointer; border-bottom: 2px solid transparent; white-space: nowrap; - transition: color 0.15s, border-color 0.15s; + transition: + color 0.15s, + border-color 0.15s; margin-bottom: -1px; } -.tab-btn:hover { color: #1a1a2e; } -.tab-btn.active { color: #1a1a2e; border-bottom-color: #1a1a2e; font-weight: 600; } +.tab-btn:hover { + color: #1a1a2e; +} +.tab-btn.active { + color: #1a1a2e; + border-bottom-color: #1a1a2e; + font-weight: 600; +} /* ── Vault list ──────────────────────────────────────────────────── */ .vault-list { @@ -159,79 +250,149 @@ body { scrollbar-width: thin; scrollbar-color: #e5e7eb transparent; } -.vault-list::-webkit-scrollbar { width: 4px; } -.vault-list::-webkit-scrollbar-thumb { background: #e5e7eb; border-radius: 2px; } +.vault-list::-webkit-scrollbar { + width: 4px; +} +.vault-list::-webkit-scrollbar-thumb { + background: #e5e7eb; + border-radius: 2px; +} .vault-item { - display: flex; align-items: center; gap: 10px; + display: flex; + align-items: center; + gap: 10px; padding: 10px 14px; border-bottom: 1px solid #f3f4f6; cursor: default; transition: background 0.1s; } -.vault-item:hover { background: #f9fafb; } -.vault-item:last-child { border-bottom: none; } +.vault-item:hover { + background: #f9fafb; +} +.vault-item:last-child { + border-bottom: none; +} .item-avatar { - width: 36px; height: 36px; + width: 36px; + height: 36px; border-radius: 8px; background: #1e2d5a; - display: flex; align-items: center; justify-content: center; + display: flex; + align-items: center; + justify-content: center; font-size: 17px; flex-shrink: 0; overflow: hidden; } -.item-avatar.color-red { background: #7f1d1d; } -.item-avatar.color-green { background: #14532d; } -.item-avatar.color-purple { background: #3b0764; } -.item-avatar.color-teal { background: #134e4a; } +.item-avatar.color-red { + background: #7f1d1d; +} +.item-avatar.color-green { + background: #14532d; +} +.item-avatar.color-purple { + background: #3b0764; +} +.item-avatar.color-teal { + background: #134e4a; +} -.item-avatar img { width: 22px; height: 22px; border-radius: 3px; } +.item-avatar img { + width: 22px; + height: 22px; + border-radius: 3px; +} -.item-info { flex: 1; min-width: 0; } +.item-info { + flex: 1; + min-width: 0; +} .item-site { - font-size: 12px; font-weight: 600; color: #374151; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-size: 12px; + font-weight: 600; + color: #374151; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .item-name { - font-size: 11px; color: #9ca3af; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-size: 11px; + color: #9ca3af; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; margin-top: 1px; } -.item-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; } +.item-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} .btn-item-action { - background: none; border: none; cursor: pointer; - padding: 4px 6px; border-radius: 5px; color: #6b7280; - display: flex; align-items: center; justify-content: center; - transition: background 0.12s, color 0.12s; + background: none; + border: none; + cursor: pointer; + padding: 4px 6px; + border-radius: 5px; + color: #6b7280; + display: flex; + align-items: center; + justify-content: center; + transition: + background 0.12s, + color 0.12s; +} +.btn-item-action:hover { + background: #f3f4f6; + color: #1a1a2e; +} +.btn-item-action svg { + width: 16px; + height: 16px; } -.btn-item-action:hover { background: #f3f4f6; color: #1a1a2e; } -.btn-item-action svg { width: 16px; height: 16px; } .badge-match { display: inline-block; - background: #dbeafe; color: #1d4ed8; - font-size: 9px; font-weight: 700; - padding: 1px 5px; border-radius: 8px; - letter-spacing: 0.3px; margin-left: 4px; + background: #dbeafe; + color: #1d4ed8; + font-size: 9px; + font-weight: 700; + padding: 1px 5px; + border-radius: 8px; + letter-spacing: 0.3px; + margin-left: 4px; vertical-align: middle; } /* ── States ──────────────────────────────────────────────────────── */ .pk-error { - font-size: 12px; color: #b91c1c; - background: #fef2f2; border-radius: 5px; - padding: 7px 10px; margin-bottom: 10px; + font-size: 12px; + color: #b91c1c; + background: #fef2f2; + border-radius: 5px; + padding: 7px 10px; + margin-bottom: 10px; +} +.pk-error.hidden { + display: none; } -.pk-error.hidden { display: none; } -.pk-loading, .pk-empty { - text-align: center; color: #9ca3af; - padding: 28px 16px; font-size: 13px; +.pk-loading, +.pk-empty { + text-align: center; + color: #9ca3af; + padding: 28px 16px; + font-size: 13px; +} +.pk-loading.hidden, +.pk-empty.hidden { + display: none; } -.pk-loading.hidden, .pk-empty.hidden { display: none; } /* ── Bottom nav ──────────────────────────────────────────────────── */ .bottom-nav { @@ -239,15 +400,183 @@ body { border-top: 1px solid #f0f0f0; background: #fff; } +.bottom-nav.hidden { + display: none; +} .nav-btn { flex: 1; - display: flex; flex-direction: column; align-items: center; justify-content: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; gap: 3px; padding: 8px 4px; - background: none; border: none; cursor: pointer; + background: none; + border: none; + cursor: pointer; color: #9ca3af; - font-size: 9px; font-weight: 500; + font-size: 9px; + font-weight: 500; transition: color 0.15s; } -.nav-btn:hover { color: #374151; } -.nav-btn.active { color: #1a1a2e; } +.nav-btn:hover { + color: #374151; +} +.nav-btn.active { + color: #1a1a2e; +} + +/* ── Save-prompt blocking modal ──────────────────────────────────── */ +.save-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} +.save-overlay.hidden { + display: none; +} +.save-modal { + background: #fff; + border-radius: 12px; + padding: 20px 18px 16px; + width: 272px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.22); +} +.save-modal-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 700; + color: #1a1a2e; + margin-bottom: 6px; +} +.save-modal-sub { + font-size: 11px; + color: #6b7280; + margin-bottom: 12px; + word-break: break-all; +} +.save-modal-actions { + display: flex; + gap: 8px; + margin-top: 10px; +} +.save-modal-actions .btn-primary { + margin-top: 0; +} +.save-modal-actions .btn-ghost { + margin-top: 0; +} + +/* ── Generator view ──────────────────────────────────────────────── */ +.gen-suggestion { + background: #e8f5f0; + border-bottom: 2px solid #16a34a; + padding: 12px 14px 10px; +} +.gen-suggestion-label { + font-size: 10px; + color: #374151; + font-weight: 500; + margin-bottom: 4px; +} +.gen-suggestion-row { + display: flex; + align-items: center; + gap: 8px; +} +.gen-output { + flex: 1; + font-family: "Courier New", Courier, monospace; + font-size: 13px; + font-weight: 700; + color: #1a1a2e; + word-break: break-all; + min-width: 0; +} +.gen-suggestion-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} +.gen-icon-btn { + background: none; + border: none; + cursor: pointer; + color: #1a73e8; + padding: 4px; + border-radius: 5px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.12s; +} +.gen-icon-btn:hover { + background: rgba(26, 115, 232, 0.1); +} + +.gen-strength-row { + padding: 6px 14px 2px; + min-height: 22px; +} +.gen-strength-label { + font-size: 12px; + font-weight: 700; +} + +.gen-body { + padding: 10px 14px 14px; + display: flex; + flex-direction: column; + gap: 10px; +} +.gen-row { + display: flex; + align-items: center; + gap: 6px; +} +.gen-label { + font-size: 12px; + color: #374151; + white-space: nowrap; +} +.gen-length-num { + width: 42px; + padding: 3px 5px; + border: 1px solid #d1d5db; + border-radius: 5px; + font-size: 12px; + text-align: center; + color: #1a1a2e; + outline: none; +} +.gen-length-num:focus { + border-color: #1a73e8; +} +.gen-slider { + width: 100%; + accent-color: #1a73e8; + cursor: pointer; + height: 4px; +} +.gen-check { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #374151; + cursor: pointer; + user-select: none; +} +.gen-check input[type="checkbox"] { + width: 15px; + height: 15px; + accent-color: #1a73e8; + cursor: pointer; + flex-shrink: 0; +} diff --git a/extension/popup/popup.html b/extension/popup/popup.html index 9fd20f0..02d9b0b 100644 --- a/extension/popup/popup.html +++ b/extension/popup/popup.html @@ -1,160 +1,411 @@ - + - - - PassKeeper - - - -
- - -