1147 lines
45 KiB
JavaScript
1147 lines
45 KiB
JavaScript
/**
|
||
* extension/popup/popup.js — PassKeeper popup UI
|
||
*
|
||
* Storage layout:
|
||
* chrome.storage.session — access_token, vault_key_jwk, vault_items (cleared on browser close)
|
||
* chrome.storage.local — refresh_token, enc_key_salt (persists across restarts)
|
||
*
|
||
* SSO flow (web app → extension):
|
||
* bridge.js syncs tokens to background → background writes to chrome.storage.
|
||
* On popup open: if session has no vault_key but local has enc_key_salt + refresh_token,
|
||
* we refresh the access_token and show the unlock-only view (master password only).
|
||
*/
|
||
|
||
const API_BASE = 'https://pwkeeper.ngodanguyen.tech';
|
||
const VAULT_URL = 'https://pwkeeper.ngodanguyen.tech/vault';
|
||
const ALERTS_URL = 'https://pwkeeper.ngodanguyen.tech/vault#security';
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────────
|
||
|
||
let _vaultKey = null;
|
||
let _items = [];
|
||
let _mfaToken = null;
|
||
let _currentUrl = '';
|
||
let _activeTab = 'relevant';
|
||
|
||
// ── DOM helpers ───────────────────────────────────────────────────────────────
|
||
|
||
const $ = id => document.getElementById(id);
|
||
|
||
function showView(name) {
|
||
['login', 'mfa', 'unlock', 'vault', 'generator', '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));
|
||
// Sync active state on nav buttons.
|
||
['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);
|
||
});
|
||
}
|
||
|
||
function showError(elId, msg) {
|
||
const el = $(elId);
|
||
el.textContent = msg;
|
||
el.classList.remove('hidden');
|
||
}
|
||
|
||
function hideError(elId) { $(elId).classList.add('hidden'); }
|
||
|
||
function escHtml(str) {
|
||
return String(str ?? '')
|
||
.replace(/&/g, '&').replace(/</g, '<')
|
||
.replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
// Auto-clear clipboard 30 s after a sensitive copy.
|
||
let _clipTimer = null;
|
||
function _copyWithAutoClear(text) {
|
||
navigator.clipboard.writeText(text).catch(() => {});
|
||
if (_clipTimer) clearTimeout(_clipTimer);
|
||
_clipTimer = setTimeout(() => {
|
||
navigator.clipboard.writeText('').catch(() => {});
|
||
_clipTimer = null;
|
||
}, 30_000);
|
||
}
|
||
|
||
// ── Avatar helpers ────────────────────────────────────────────────────────────
|
||
|
||
const AVATAR_COLORS = ['', 'color-red', 'color-green', 'color-purple', 'color-teal'];
|
||
|
||
function avatarColor(str) {
|
||
let h = 0;
|
||
for (const c of str) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||
return AVATAR_COLORS[h % AVATAR_COLORS.length];
|
||
}
|
||
|
||
function itemEmoji(type) {
|
||
return { password: '🔑', note: '📝', card: '💳', bank: '🏦', address: '🏠', ssn: '🪪', passkey: '🔐' }[type] || '🔑';
|
||
}
|
||
|
||
function siteLabel(item) {
|
||
if (item.plain?.url) {
|
||
try { return new URL(item.plain.url).hostname.replace(/^www\./, ''); } catch { }
|
||
}
|
||
return item.name;
|
||
}
|
||
|
||
// ── Domain matching ───────────────────────────────────────────────────────────
|
||
|
||
function currentHostname() {
|
||
if (!_currentUrl) return '';
|
||
try { return new URL(_currentUrl).hostname.replace(/^www\./, ''); } catch { return ''; }
|
||
}
|
||
|
||
/**
|
||
* Normalise a stored URL so it is always parseable by `new URL()`.
|
||
* Handles bare domains ("github.com"), protocol-relative, and full URLs.
|
||
*/
|
||
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;
|
||
}
|
||
|
||
function isMatch(item) {
|
||
const host = currentHostname();
|
||
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\./, '');
|
||
// Match exact domain or any subdomain relationship.
|
||
return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`);
|
||
} catch { return false; }
|
||
}
|
||
|
||
// ── API helpers ───────────────────────────────────────────────────────────────
|
||
|
||
async function apiFetch(path, options = {}) {
|
||
const { access_token } = await chrome.storage.session.get('access_token');
|
||
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
|
||
if (access_token) headers['Authorization'] = `Bearer ${access_token}`;
|
||
|
||
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||
|
||
if (res.status === 401) {
|
||
const refreshed = await tryRefreshToken();
|
||
if (!refreshed) { signOut(); return null; }
|
||
const { access_token: tok } = await chrome.storage.session.get('access_token');
|
||
headers['Authorization'] = `Bearer ${tok}`;
|
||
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||
}
|
||
return res;
|
||
}
|
||
|
||
async function tryRefreshToken() {
|
||
const { refresh_token } = await chrome.storage.local.get('refresh_token');
|
||
if (!refresh_token) return false;
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/auth/refresh`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ refresh_token }),
|
||
});
|
||
if (!res.ok) return false;
|
||
const data = await res.json();
|
||
await chrome.storage.session.set({ access_token: data.access_token });
|
||
if (data.refresh_token) await chrome.storage.local.set({ refresh_token: data.refresh_token });
|
||
return true;
|
||
} catch { return false; }
|
||
}
|
||
|
||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||
|
||
async function handleLogin() {
|
||
const email = $('login-email').value.trim().toLowerCase();
|
||
const password = $('login-password').value;
|
||
hideError('login-error');
|
||
if (!email || !password) { showError('login-error', 'Email and master password are required.'); return; }
|
||
|
||
const btn = $('btn-login');
|
||
btn.disabled = true; btn.textContent = 'Unlocking…';
|
||
try {
|
||
const authHash = await ExtCrypto.deriveAuthHash(password, email);
|
||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ email, auth_hash: authHash }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) { showError('login-error', data.error || 'Login failed.'); return; }
|
||
|
||
if (data.mfa_required) {
|
||
_mfaToken = data.mfa_token;
|
||
await chrome.storage.session.set({ _pending_enc_key_salt: data.enc_key_salt });
|
||
handleMfaStage(password);
|
||
showView('mfa');
|
||
$('mfa-code').focus();
|
||
return;
|
||
}
|
||
await completeLogin(data, password);
|
||
} catch (err) {
|
||
showError('login-error', 'Network error: ' + err.message);
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = 'Unlock Vault';
|
||
}
|
||
}
|
||
|
||
function handleMfaStage(password) {
|
||
$('btn-mfa-verify').onclick = async () => {
|
||
const code = $('mfa-code').value.trim();
|
||
hideError('mfa-error');
|
||
if (code.length !== 6) { showError('mfa-error', 'Enter the 6-digit code.'); return; }
|
||
$('btn-mfa-verify').disabled = true;
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/auth/mfa/verify`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ mfa_token: _mfaToken, totp_code: code }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) { showError('mfa-error', data.error || 'Verification failed.'); return; }
|
||
_mfaToken = null;
|
||
const { _pending_enc_key_salt } = await chrome.storage.session.get('_pending_enc_key_salt');
|
||
await completeLogin({ ...data, enc_key_salt: _pending_enc_key_salt }, password);
|
||
} catch (err) {
|
||
showError('mfa-error', 'Error: ' + err.message);
|
||
} finally {
|
||
$('btn-mfa-verify').disabled = false;
|
||
}
|
||
};
|
||
}
|
||
|
||
async function completeLogin(data, masterPassword) {
|
||
await chrome.storage.session.set({ access_token: data.access_token, enc_key_salt: data.enc_key_salt });
|
||
// enc_key_salt also persisted locally — not sensitive without the master password,
|
||
// needed to show unlock-only view after browser restart.
|
||
await chrome.storage.local.set({ refresh_token: data.refresh_token, enc_key_salt: data.enc_key_salt });
|
||
|
||
_vaultKey = await ExtCrypto.deriveVaultKey(masterPassword, data.enc_key_salt);
|
||
const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey);
|
||
await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk });
|
||
|
||
// Push session to any open web app tabs so they don't need to re-login
|
||
chrome.runtime.sendMessage({
|
||
type: 'EXT_SESSION_SYNC',
|
||
access_token: data.access_token,
|
||
refresh_token: data.refresh_token,
|
||
enc_key_salt: data.enc_key_salt,
|
||
}).catch(() => { });
|
||
|
||
showView('vault');
|
||
await checkPendingSave();
|
||
await fetchAndDecryptVault();
|
||
renderList();
|
||
}
|
||
|
||
// ── Unlock-only (tokens from web app, just need master password) ──────────────
|
||
|
||
async function handleUnlockOnly() {
|
||
const password = $('unlock-password').value;
|
||
hideError('unlock-error');
|
||
if (!password) { showError('unlock-error', 'Enter your master password.'); return; }
|
||
|
||
const btn = $('btn-unlock');
|
||
btn.disabled = true; btn.textContent = 'Unlocking…';
|
||
try {
|
||
// enc_key_salt may be in session (fresh) or local (after browser restart)
|
||
let { enc_key_salt } = await chrome.storage.session.get('enc_key_salt');
|
||
if (!enc_key_salt) {
|
||
const local = await chrome.storage.local.get('enc_key_salt');
|
||
enc_key_salt = local.enc_key_salt;
|
||
}
|
||
if (!enc_key_salt) { showError('unlock-error', 'Session expired. Please log in again.'); return; }
|
||
|
||
_vaultKey = await ExtCrypto.deriveVaultKey(password, enc_key_salt);
|
||
const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey);
|
||
await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk });
|
||
|
||
showView('vault');
|
||
await checkPendingSave();
|
||
await fetchAndDecryptVault();
|
||
renderList();
|
||
} catch {
|
||
showError('unlock-error', 'Incorrect password or session expired.');
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = 'Unlock';
|
||
}
|
||
}
|
||
|
||
async function signOut() {
|
||
try {
|
||
const { access_token } = await chrome.storage.session.get('access_token');
|
||
const { refresh_token } = await chrome.storage.local.get('refresh_token');
|
||
if (access_token) {
|
||
fetch(`${API_BASE}/api/auth/logout`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${access_token}` },
|
||
body: JSON.stringify({ refresh_token }),
|
||
}).catch(() => { });
|
||
}
|
||
} catch { }
|
||
await chrome.storage.session.clear();
|
||
await chrome.storage.local.remove(['refresh_token', 'enc_key_salt']);
|
||
// vault_items_cs is now in session storage — cleared by the session.clear() call above.
|
||
_vaultKey = null; _items = [];
|
||
showView('login');
|
||
}
|
||
|
||
// ── Vault loading ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Returns:
|
||
* 'vault' — fully unlocked session restored
|
||
* 'unlock' — tokens exist (from web app SSO) but vault key needs deriving
|
||
* false — no session, show login
|
||
*/
|
||
async function restoreSessionIfAvailable() {
|
||
const data = await chrome.storage.session.get(['access_token', 'vault_key_jwk', 'vault_items', 'enc_key_salt']);
|
||
|
||
if (data.access_token) {
|
||
if (data.vault_key_jwk) {
|
||
_vaultKey = await ExtCrypto.importVaultKey(data.vault_key_jwk);
|
||
_items = data.vault_items || [];
|
||
return 'vault';
|
||
}
|
||
// Tokens exist (synced from web app) but vault key not yet derived
|
||
if (data.enc_key_salt) return 'unlock';
|
||
}
|
||
|
||
// Session cleared (e.g. browser restart) — try refreshing with persisted local tokens
|
||
const local = await chrome.storage.local.get(['refresh_token', 'enc_key_salt']);
|
||
if (local.refresh_token && local.enc_key_salt) {
|
||
const refreshed = await tryRefreshToken();
|
||
if (refreshed) {
|
||
await chrome.storage.session.set({ enc_key_salt: local.enc_key_salt });
|
||
return 'unlock';
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
async function fetchAndDecryptVault() {
|
||
$('vault-spinner').classList.remove('hidden');
|
||
try {
|
||
const res = await apiFetch('/api/vault');
|
||
if (!res) return;
|
||
const raw = await res.json();
|
||
|
||
_items = await Promise.all(raw.map(async item => {
|
||
try {
|
||
const plain = await ExtCrypto.decryptItem(_vaultKey, item.enc_data, item.iv);
|
||
// 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 }; }
|
||
}));
|
||
|
||
// Write to session for the popup's own use (badge, rendering).
|
||
await chrome.storage.session.set({ vault_items: _items });
|
||
|
||
// 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 => ({
|
||
id: item.id,
|
||
name: item.name,
|
||
item_type: item.item_type,
|
||
plain: item.plain,
|
||
}));
|
||
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(() => { });
|
||
} catch (err) {
|
||
console.error('fetchAndDecryptVault:', err);
|
||
} finally {
|
||
$('vault-spinner').classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// ── TOTP engine (RFC 6238) — pure Web Crypto, no library ─────────────────────
|
||
|
||
function _extractTotpSecret(uri) {
|
||
if (!uri) return null;
|
||
uri = uri.trim();
|
||
if (uri.startsWith('otpauth://')) {
|
||
try {
|
||
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;
|
||
}
|
||
|
||
function _base32ToBytes(b32) {
|
||
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); }
|
||
}
|
||
return new Uint8Array(out);
|
||
}
|
||
|
||
async function getTotpCode(totpUri) {
|
||
const secret = _extractTotpSecret(totpUri);
|
||
if (!secret) return null;
|
||
const keyBytes = _base32ToBytes(secret);
|
||
if (!keyBytes.length) return null;
|
||
|
||
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); }
|
||
|
||
const cryptoKey = await crypto.subtle.importKey(
|
||
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']
|
||
);
|
||
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');
|
||
}
|
||
|
||
function _totpSecondsLeft() {
|
||
return 30 - (Math.floor(Date.now() / 1000) % 30);
|
||
}
|
||
|
||
// Track active TOTP ticker intervals so we can clear them on re-render.
|
||
let _totpIntervals = [];
|
||
|
||
function _clearTotpTickers() {
|
||
_totpIntervals.forEach(id => clearInterval(id));
|
||
_totpIntervals = [];
|
||
}
|
||
|
||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
||
|
||
function getTabItems() {
|
||
const q = $('vault-search').value.trim().toLowerCase();
|
||
|
||
let items = _items;
|
||
|
||
// Text filter
|
||
if (q) {
|
||
items = items.filter(i =>
|
||
i.name.toLowerCase().includes(q) ||
|
||
(i.plain?.username || '').toLowerCase().includes(q) ||
|
||
(i.plain?.url || '').toLowerCase().includes(q)
|
||
);
|
||
}
|
||
|
||
if (_activeTab === 'relevant') {
|
||
// Show ONLY items that match the current domain, sorted by name.
|
||
items = items.filter(isMatch).sort((a, b) => a.name.localeCompare(b.name));
|
||
} else if (_activeTab === 'recents') {
|
||
items = [...items].sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at)).slice(0, 20);
|
||
} else {
|
||
items = [...items].sort((a, b) => a.name.localeCompare(b.name));
|
||
}
|
||
|
||
return items;
|
||
}
|
||
|
||
function renderList() {
|
||
const listEl = $('vault-list');
|
||
const emptyEl = $('vault-empty');
|
||
const items = getTabItems();
|
||
|
||
// Stop any running TOTP tickers from a previous render.
|
||
_clearTotpTickers();
|
||
|
||
if (!items.length) {
|
||
listEl.innerHTML = '';
|
||
if (_activeTab === 'relevant') {
|
||
const host = currentHostname();
|
||
emptyEl.textContent = host
|
||
? `No saved passwords for ${host}.`
|
||
: 'No items match this page.';
|
||
} else {
|
||
emptyEl.textContent = 'No items found.';
|
||
}
|
||
emptyEl.classList.remove('hidden');
|
||
return;
|
||
}
|
||
emptyEl.classList.add('hidden');
|
||
|
||
// SVG icons for action buttons
|
||
const svgCopy = `<svg viewBox="0 0 24 24" fill="none"><rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke="currentColor" stroke-width="1.7"/></svg>`;
|
||
const svgUser = `<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="1.7"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`;
|
||
const svgDots = `<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>`;
|
||
const svgFill = `<svg viewBox="0 0 24 24" fill="none"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M17 3l4 4-9 9H8v-4l9-9z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>`;
|
||
const svgTotp = `<svg viewBox="0 0 24 24" fill="none" width="16" height="16"><rect x="5" y="2" width="14" height="20" rx="2" stroke="currentColor" stroke-width="1.7"/><circle cx="12" cy="17" r="1" fill="currentColor"/><path d="M9 7h6M9 11h4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`;
|
||
|
||
listEl.innerHTML = items.map(item => {
|
||
const matched = isMatch(item);
|
||
const site = escHtml(siteLabel(item));
|
||
const name = escHtml(item.name);
|
||
const badge = matched ? '<span class="badge-match">match</span>' : '';
|
||
const color = avatarColor(item.name);
|
||
const emoji = itemEmoji(item.item_type);
|
||
const canFill = item.item_type === 'password' && item.plain?.username && item.plain?.password;
|
||
const canCopy = item.item_type === 'password' && item.plain?.password;
|
||
const hasTotp = item.item_type === 'password' && !!_extractTotpSecret(item.plain?.totp_uri);
|
||
|
||
return `<div class="vault-item" data-id="${item.id}">
|
||
<div class="item-avatar ${color}">${emoji}</div>
|
||
<div class="item-info">
|
||
<div class="item-site">${site}${badge}</div>
|
||
<div class="item-name">${name}</div>
|
||
${hasTotp ? `<div class="item-totp-row"><span class="totp-code-inline" id="totp-${item.id}">······</span><span class="totp-timer-inline" id="totp-t-${item.id}"></span></div>` : ''}
|
||
</div>
|
||
<div class="item-actions">
|
||
${item.plain?.username ? `<button class="btn-item-action" data-copy-user="${item.id}" title="Copy username">${svgUser}</button>` : ''}
|
||
${canCopy ? `<button class="btn-item-action" data-copy-pass="${item.id}" title="Copy password">${svgCopy}</button>` : ''}
|
||
${hasTotp ? `<button class="btn-item-action totp-btn" data-copy-totp="${item.id}" title="Copy 2FA code">${svgTotp}</button>` : ''}
|
||
${canFill ? `<button class="btn-item-action" data-autofill="${item.id}" title="Autofill">${svgFill}</button>` : ''}
|
||
<button class="btn-item-action" data-menu="${item.id}" title="More options">${svgDots}</button>
|
||
</div>
|
||
</div>`;
|
||
}).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;
|
||
const codeEl = $(`totp-${item.id}`);
|
||
const timerEl = $(`totp-t-${item.id}`);
|
||
if (!codeEl) return;
|
||
|
||
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);
|
||
const secs = _totpSecondsLeft();
|
||
timerEl.textContent = ' ' + secs + 's';
|
||
timerEl.style.color = secs <= 5 ? '#dc2626' : '#9ca3af';
|
||
}
|
||
tick();
|
||
const id = setInterval(tick, 1000);
|
||
_totpIntervals.push(id);
|
||
});
|
||
|
||
// Copy password
|
||
listEl.querySelectorAll('[data-copy-pass]').forEach(btn =>
|
||
btn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const item = _items.find(i => i.id === parseInt(btn.dataset.copyPass));
|
||
if (item?.plain?.password) {
|
||
_copyWithAutoClear(item.plain.password);
|
||
btn.title = 'Copied!';
|
||
setTimeout(() => { btn.title = 'Copy password'; }, 1500);
|
||
}
|
||
})
|
||
);
|
||
|
||
// Copy TOTP code
|
||
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));
|
||
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);
|
||
}
|
||
})
|
||
);
|
||
|
||
// Autofill
|
||
listEl.querySelectorAll('[data-autofill]').forEach(btn =>
|
||
btn.addEventListener('click', async e => {
|
||
e.stopPropagation();
|
||
const item = _items.find(i => i.id === parseInt(btn.dataset.autofill));
|
||
if (!item?.plain) return;
|
||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||
if (tab?.id) {
|
||
chrome.tabs.sendMessage(tab.id, {
|
||
type: 'DO_AUTOFILL',
|
||
username: item.plain.username || '',
|
||
password: item.plain.password || '',
|
||
}).catch(() => { });
|
||
}
|
||
window.close();
|
||
})
|
||
);
|
||
|
||
// Copy username (dedicated button)
|
||
listEl.querySelectorAll('[data-copy-user]').forEach(btn =>
|
||
btn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
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);
|
||
}
|
||
})
|
||
);
|
||
|
||
// Three-dot menu: proper flyout with contextual actions
|
||
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());
|
||
|
||
const item = _items.find(i => i.id === parseInt(btn.dataset.menu));
|
||
if (!item) return;
|
||
|
||
const flyout = document.createElement('div');
|
||
flyout.className = 'pk-flyout';
|
||
|
||
const menuItems = [
|
||
item.plain?.url ? {
|
||
label: 'Open URL',
|
||
icon: '<svg viewBox="0 0 24 24" fill="none" width="14" height="14"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M15 3h6v6M10 14L21 3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
||
action: () => { chrome.tabs.create({ url: item.plain.url }); flyout.remove(); },
|
||
} : null,
|
||
item.plain?.username ? {
|
||
label: 'Copy username',
|
||
icon: '<svg viewBox="0 0 24 24" fill="none" width="14" height="14"><circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="1.7"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>',
|
||
action: () => { _copyWithAutoClear(item.plain.username); flyout.remove(); },
|
||
} : null,
|
||
item.plain?.password ? {
|
||
label: 'Copy password',
|
||
icon: '<svg viewBox="0 0 24 24" fill="none" width="14" height="14"><rect x="3" y="10" width="18" height="11" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 10V7a4 4 0 018 0v3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>',
|
||
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 + '<span>' + escHtml(mi.label) + '</span>';
|
||
row.addEventListener('click', e => { e.stopPropagation(); mi.action(); });
|
||
flyout.appendChild(row);
|
||
});
|
||
|
||
// Position flyout below the button, right-aligned.
|
||
btn.style.position = 'relative';
|
||
btn.appendChild(flyout);
|
||
|
||
// Close on any outside click.
|
||
setTimeout(() => {
|
||
document.addEventListener('click', function handler() {
|
||
flyout.remove();
|
||
document.removeEventListener('click', handler);
|
||
});
|
||
}, 0);
|
||
})
|
||
);
|
||
}
|
||
|
||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||
|
||
function initTabs() {
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
_activeTab = btn.dataset.tab;
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b === btn));
|
||
renderList();
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Save-prompt modal ─────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Populate the folder <select> inside the save modal.
|
||
* Fetches /api/folders and rebuilds the option list.
|
||
* The first option ("— No folder —") with value "" is always kept.
|
||
*/
|
||
async function loadFoldersIntoSaveModal() {
|
||
const select = $('save-folder');
|
||
// Reset to just the placeholder option.
|
||
select.innerHTML = '<option value="">— No folder —</option>';
|
||
try {
|
||
const res = await apiFetch('/api/folders');
|
||
if (!res?.ok) return;
|
||
const folders = await res.json();
|
||
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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show the save-prompt overlay. It is a blocking modal — no backdrop click,
|
||
* no ✕ button — so the user MUST click "Save" or "Not now".
|
||
* Called every time the popup opens while a pending_save is stored.
|
||
*
|
||
* pending_save is stored in chrome.storage.local (not session) so it survives
|
||
* service worker restarts and is reliably present when the popup re-opens.
|
||
*/
|
||
async function checkPendingSave() {
|
||
const { pending_save } = await chrome.storage.local.get('pending_save');
|
||
if (!pending_save) return;
|
||
|
||
// Populate fields.
|
||
$('save-name').value = pending_save.siteName || '';
|
||
$('save-username').value = pending_save.username || '';
|
||
$('save-prompt-subtitle').textContent =
|
||
`${pending_save.username || '(no username)'} on ${pending_save.siteName || pending_save.url || 'unknown site'}`;
|
||
$('save-prompt-title').textContent =
|
||
pending_save._isUpdate ? 'Update in PassKeeper?' : 'Save to PassKeeper?';
|
||
|
||
// Load folders (non-blocking — modal shows immediately, folders populate async).
|
||
$('save-folder').value = '';
|
||
loadFoldersIntoSaveModal();
|
||
|
||
// Show the overlay.
|
||
$('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(() => {});
|
||
}
|
||
|
||
$('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);
|
||
_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';
|
||
// Read selected folder — empty string means no folder (null).
|
||
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 }),
|
||
});
|
||
if (res?.ok) {
|
||
console.log('[PassKeeper] Credential saved to vault:', name, '(name encrypted), folder_id:', folder_id);
|
||
await fetchAndDecryptVault();
|
||
renderList();
|
||
}
|
||
} catch (err) { console.error('saveCredential failed:', err); }
|
||
}
|
||
|
||
// ── Password Generator ────────────────────────────────────────────────────────
|
||
|
||
const GEN_SETS = {
|
||
lower: 'abcdefghijklmnopqrstuvwxyz',
|
||
upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||
numbers: '0123456789',
|
||
symbols: '!@#$%^&*()-_=+[]{}|;:,.<>?',
|
||
};
|
||
|
||
/**
|
||
* Cryptographically secure random integer in [0, max).
|
||
* Uses crypto.getRandomValues exclusively — Math.random() is never called.
|
||
*/
|
||
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);
|
||
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 '';
|
||
|
||
// 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)]);
|
||
|
||
const arr = new Uint32Array(length);
|
||
crypto.getRandomValues(arr);
|
||
const rest = Array.from(arr).map(n => pool[n % pool.length]);
|
||
|
||
// Splice required chars into random positions and trim to length.
|
||
const combined = [...rest];
|
||
required.forEach((ch, i) => { combined[i] = ch; });
|
||
// Fisher-Yates shuffle — 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('');
|
||
}
|
||
|
||
function passwordStrength(pw) {
|
||
if (!pw) return { label: '', color: '' };
|
||
let score = 0;
|
||
if (pw.length >= 8) score++;
|
||
if (pw.length >= 12) score++;
|
||
if (pw.length >= 16) score++;
|
||
if (/[a-z]/.test(pw)) score++;
|
||
if (/[A-Z]/.test(pw)) score++;
|
||
if (/[0-9]/.test(pw)) score++;
|
||
if (/[^a-zA-Z0-9]/.test(pw)) score++;
|
||
if (score <= 2) return { label: 'Weak', color: '#dc2626' };
|
||
if (score <= 4) return { label: 'Fair', color: '#d97706' };
|
||
if (score <= 5) return { label: 'Good', color: '#2563eb' };
|
||
if (score <= 6) return { label: 'Strong', color: '#16a34a' };
|
||
return { label: 'Very Strong', color: '#15803d' };
|
||
}
|
||
|
||
let _genInitialised = false;
|
||
|
||
function initGenerator() {
|
||
// Only bind events once — subsequent calls just re-generate a password.
|
||
if (!_genInitialised) {
|
||
_genInitialised = true;
|
||
|
||
const slider = $('gen-length');
|
||
const numInput = $('gen-length-num');
|
||
|
||
// Keep slider and number input in sync.
|
||
slider.addEventListener('input', () => {
|
||
numInput.value = slider.value;
|
||
refreshGen();
|
||
});
|
||
numInput.addEventListener('input', () => {
|
||
const v = Math.min(64, Math.max(8, parseInt(numInput.value) || 16));
|
||
slider.value = v;
|
||
numInput.value = v;
|
||
refreshGen();
|
||
});
|
||
|
||
['gen-lower', 'gen-upper', 'gen-numbers', 'gen-symbols'].forEach(id => {
|
||
$(id).addEventListener('change', refreshGen);
|
||
});
|
||
|
||
$('btn-gen-refresh').addEventListener('click', refreshGen);
|
||
|
||
$('btn-gen-copy').addEventListener('click', () => {
|
||
const pw = $('gen-output').textContent;
|
||
if (!pw || pw === 'Click refresh to generate') return;
|
||
navigator.clipboard.writeText(pw).then(() => {
|
||
$('btn-gen-copy').title = 'Copied!';
|
||
setTimeout(() => { $('btn-gen-copy').title = 'Copy password'; }, 1500);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Always generate a fresh password when the view opens.
|
||
refreshGen();
|
||
}
|
||
|
||
function refreshGen() {
|
||
const length = Math.min(64, Math.max(8, parseInt($('gen-length').value) || 16));
|
||
const useLower = $('gen-lower').checked;
|
||
const useUpper = $('gen-upper').checked;
|
||
const useNumbers = $('gen-numbers').checked;
|
||
const useSymbols = $('gen-symbols').checked;
|
||
|
||
// Require at least one charset.
|
||
if (!useLower && !useUpper && !useNumbers && !useSymbols) {
|
||
$('gen-output').textContent = 'Select at least one character type';
|
||
$('gen-strength-label').textContent = '';
|
||
return;
|
||
}
|
||
|
||
const pw = generatePassword(length, useLower, useUpper, useNumbers, useSymbols);
|
||
$('gen-output').textContent = pw;
|
||
|
||
const { label, color } = passwordStrength(pw);
|
||
const strengthEl = $('gen-strength-label');
|
||
strengthEl.textContent = label;
|
||
strengthEl.style.color = color;
|
||
}
|
||
|
||
// ── Add Item view ─────────────────────────────────────────────────────────────
|
||
|
||
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;
|
||
// 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\./, '');
|
||
}
|
||
}
|
||
} catch (e) { }
|
||
|
||
// Load folders into the add-folder select.
|
||
const addFolderSel = $('add-folder');
|
||
addFolderSel.innerHTML = '<option value="">— No folder —</option>';
|
||
try {
|
||
const res = await apiFetch('/api/folders');
|
||
if (res?.ok) {
|
||
const folders = await res.json();
|
||
folders.forEach(f => {
|
||
const opt = document.createElement('option');
|
||
opt.value = f.id;
|
||
opt.textContent = f.name;
|
||
addFolderSel.appendChild(opt);
|
||
});
|
||
}
|
||
} catch (e) { }
|
||
|
||
// Wire one-time event listeners.
|
||
if (!_addViewInitialised) {
|
||
_addViewInitialised = true;
|
||
|
||
// Back button.
|
||
$('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';
|
||
});
|
||
|
||
// Generate password — generates via the generator logic and pastes into field.
|
||
$('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
|
||
});
|
||
|
||
// Save button.
|
||
$('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(); });
|
||
});
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
|
||
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 folder_id = folderVal ? parseInt(folderVal, 10) : null;
|
||
|
||
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; }
|
||
|
||
const btn = $('btn-add-save');
|
||
btn.disabled = true;
|
||
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 }),
|
||
});
|
||
if (!res?.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
showError('add-error', data.error || 'Failed to save. Please try again.');
|
||
return;
|
||
}
|
||
console.log('[PassKeeper] Item added to vault:', name, '(name encrypted)');
|
||
// Refresh vault and go back.
|
||
await fetchAndDecryptVault();
|
||
renderList();
|
||
showView('vault');
|
||
} catch (err) {
|
||
showError('add-error', 'Network error: ' + err.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Save to Vault';
|
||
}
|
||
}
|
||
|
||
// ── Account view ──────────────────────────────────────────────────────────────
|
||
|
||
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');
|
||
// Find the matching option; fall back to 600 if not found.
|
||
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);
|
||
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(() => { });
|
||
// 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');
|
||
});
|
||
|
||
$('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 { }
|
||
|
||
// Set vault link
|
||
$('btn-open-vault').href = VAULT_URL;
|
||
|
||
const sessionState = await restoreSessionIfAvailable();
|
||
|
||
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');
|
||
if (popup_nav) {
|
||
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(() => { });
|
||
// Wire event listeners below, then return early from vault-specific setup.
|
||
} else {
|
||
showView('vault');
|
||
await checkPendingSave();
|
||
if (_items.length) {
|
||
renderList();
|
||
fetchAndDecryptVault().then(() => renderList());
|
||
} else {
|
||
await fetchAndDecryptVault();
|
||
renderList();
|
||
}
|
||
}
|
||
} else {
|
||
showView('vault');
|
||
await checkPendingSave();
|
||
if (_items.length) {
|
||
renderList();
|
||
fetchAndDecryptVault().then(() => renderList());
|
||
} else {
|
||
await fetchAndDecryptVault();
|
||
renderList();
|
||
}
|
||
}
|
||
} else if (sessionState === 'unlock') {
|
||
showView('unlock');
|
||
$('unlock-password').focus();
|
||
} else {
|
||
showView('login');
|
||
}
|
||
|
||
// Login
|
||
$('btn-login').addEventListener('click', handleLogin);
|
||
$('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') handleLogin(); });
|
||
$('login-email').addEventListener('keydown', e => { if (e.key === 'Enter') $('login-password').focus(); });
|
||
|
||
// MFA
|
||
$('btn-mfa-back').addEventListener('click', () => { _mfaToken = null; showView('login'); });
|
||
$('mfa-code').addEventListener('keydown', e => { if (e.key === 'Enter') $('btn-mfa-verify')?.click(); });
|
||
|
||
// Unlock-only
|
||
$('btn-unlock').addEventListener('click', handleUnlockOnly);
|
||
$('unlock-password').addEventListener('keydown', e => { if (e.key === 'Enter') handleUnlockOnly(); });
|
||
$('btn-unlock-signout').addEventListener('click', signOut);
|
||
|
||
// Vault actions
|
||
$('btn-add-item').addEventListener('click', () => { 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(() => { });
|
||
}, 20_000);
|
||
|
||
// Search
|
||
$('vault-search').addEventListener('input', () => renderList());
|
||
|
||
// Tabs
|
||
initTabs();
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', init); |