Files
PassKeeper/extension/popup/popup.js
T

935 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Avatar helpers ────────────────────────────────────────────────────────────
const AVATAR_COLORS = ['', 'color-red', 'color-green', 'color-purple', 'color-teal'];
function avatarColor(str) {
let h = 0;
for (const c of str) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return AVATAR_COLORS[h % AVATAR_COLORS.length];
}
function itemEmoji(type) {
return { password: '🔑', note: '📝', card: '💳', bank: '🏦', address: '🏠', ssn: '🪪', passkey: '🔐' }[type] || '🔑';
}
function siteLabel(item) {
if (item.plain?.url) {
try { return new URL(item.plain.url).hostname.replace(/^www\./, ''); } catch { }
}
return item.name;
}
// ── Domain matching ───────────────────────────────────────────────────────────
function currentHostname() {
if (!_currentUrl) return '';
try { return new URL(_currentUrl).hostname.replace(/^www\./, ''); } catch { return ''; }
}
function isMatch(item) {
const host = currentHostname();
if (!host || item.item_type !== 'password' || !item.plain?.url) return false;
try {
const h = new URL(item.plain.url).hostname.replace(/^www\./, '');
return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`);
} catch { return false; }
}
// ── API helpers ───────────────────────────────────────────────────────────────
async function apiFetch(path, options = {}) {
const { access_token } = await chrome.storage.session.get('access_token');
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
if (access_token) headers['Authorization'] = `Bearer ${access_token}`;
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401) {
const refreshed = await tryRefreshToken();
if (!refreshed) { signOut(); return null; }
const { access_token: tok } = await chrome.storage.session.get('access_token');
headers['Authorization'] = `Bearer ${tok}`;
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
}
return res;
}
async function tryRefreshToken() {
const { refresh_token } = await chrome.storage.local.get('refresh_token');
if (!refresh_token) return false;
try {
const res = await fetch(`${API_BASE}/api/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token }),
});
if (!res.ok) return false;
const data = await res.json();
await chrome.storage.session.set({ access_token: data.access_token });
if (data.refresh_token) await chrome.storage.local.set({ refresh_token: data.refresh_token });
return true;
} catch { return false; }
}
// ── Auth ──────────────────────────────────────────────────────────────────────
async function handleLogin() {
const email = $('login-email').value.trim().toLowerCase();
const password = $('login-password').value;
hideError('login-error');
if (!email || !password) { showError('login-error', 'Email and master password are required.'); return; }
const btn = $('btn-login');
btn.disabled = true; btn.textContent = 'Unlocking…';
try {
const authHash = await ExtCrypto.deriveAuthHash(password, email);
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, auth_hash: authHash }),
});
const data = await res.json();
if (!res.ok) { showError('login-error', data.error || 'Login failed.'); return; }
if (data.mfa_required) {
_mfaToken = data.mfa_token;
await chrome.storage.session.set({ _pending_enc_key_salt: data.enc_key_salt });
handleMfaStage(password);
showView('mfa');
$('mfa-code').focus();
return;
}
await completeLogin(data, password);
} catch (err) {
showError('login-error', 'Network error: ' + err.message);
} finally {
btn.disabled = false; btn.textContent = 'Unlock Vault';
}
}
function handleMfaStage(password) {
$('btn-mfa-verify').onclick = async () => {
const code = $('mfa-code').value.trim();
hideError('mfa-error');
if (code.length !== 6) { showError('mfa-error', 'Enter the 6-digit code.'); return; }
$('btn-mfa-verify').disabled = true;
try {
const res = await fetch(`${API_BASE}/api/auth/mfa/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mfa_token: _mfaToken, totp_code: code }),
});
const data = await res.json();
if (!res.ok) { showError('mfa-error', data.error || 'Verification failed.'); return; }
_mfaToken = null;
const { _pending_enc_key_salt } = await chrome.storage.session.get('_pending_enc_key_salt');
await completeLogin({ ...data, enc_key_salt: _pending_enc_key_salt }, password);
} catch (err) {
showError('mfa-error', 'Error: ' + err.message);
} finally {
$('btn-mfa-verify').disabled = false;
}
};
}
async function completeLogin(data, masterPassword) {
await chrome.storage.session.set({ access_token: data.access_token, enc_key_salt: data.enc_key_salt });
// enc_key_salt also persisted locally — not sensitive without the master password,
// needed to show unlock-only view after browser restart.
await chrome.storage.local.set({ refresh_token: data.refresh_token, enc_key_salt: data.enc_key_salt });
_vaultKey = await ExtCrypto.deriveVaultKey(masterPassword, data.enc_key_salt);
const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey);
await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk });
// Push session to any open web app tabs so they don't need to re-login
chrome.runtime.sendMessage({
type: 'EXT_SESSION_SYNC',
access_token: data.access_token,
refresh_token: data.refresh_token,
enc_key_salt: data.enc_key_salt,
}).catch(() => { });
showView('vault');
await checkPendingSave();
await fetchAndDecryptVault();
renderList();
}
// ── Unlock-only (tokens from web app, just need master password) ──────────────
async function handleUnlockOnly() {
const password = $('unlock-password').value;
hideError('unlock-error');
if (!password) { showError('unlock-error', 'Enter your master password.'); return; }
const btn = $('btn-unlock');
btn.disabled = true; btn.textContent = 'Unlocking…';
try {
// enc_key_salt may be in session (fresh) or local (after browser restart)
let { enc_key_salt } = await chrome.storage.session.get('enc_key_salt');
if (!enc_key_salt) {
const local = await chrome.storage.local.get('enc_key_salt');
enc_key_salt = local.enc_key_salt;
}
if (!enc_key_salt) { showError('unlock-error', 'Session expired. Please log in again.'); return; }
_vaultKey = await ExtCrypto.deriveVaultKey(password, enc_key_salt);
const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey);
await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk });
showView('vault');
await checkPendingSave();
await fetchAndDecryptVault();
renderList();
} catch {
showError('unlock-error', 'Incorrect password or session expired.');
} finally {
btn.disabled = false; btn.textContent = 'Unlock';
}
}
async function signOut() {
try {
const { access_token } = await chrome.storage.session.get('access_token');
const { refresh_token } = await chrome.storage.local.get('refresh_token');
if (access_token) {
fetch(`${API_BASE}/api/auth/logout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${access_token}` },
body: JSON.stringify({ refresh_token }),
}).catch(() => { });
}
} catch { }
await chrome.storage.session.clear();
await chrome.storage.local.remove(['refresh_token', 'enc_key_salt', 'vault_items_cs']);
_vaultKey = null; _items = [];
showView('login');
}
// ── Vault loading ─────────────────────────────────────────────────────────────
/**
* Returns:
* 'vault' — fully unlocked session restored
* 'unlock' — tokens exist (from web app SSO) but vault key needs deriving
* false — no session, show login
*/
async function restoreSessionIfAvailable() {
const data = await chrome.storage.session.get(['access_token', 'vault_key_jwk', 'vault_items', 'enc_key_salt']);
if (data.access_token) {
if (data.vault_key_jwk) {
_vaultKey = await ExtCrypto.importVaultKey(data.vault_key_jwk);
_items = data.vault_items || [];
return 'vault';
}
// Tokens exist (synced from web app) but vault key not yet derived
if (data.enc_key_salt) return 'unlock';
}
// Session cleared (e.g. browser restart) — try refreshing with persisted local tokens
const local = await chrome.storage.local.get(['refresh_token', 'enc_key_salt']);
if (local.refresh_token && local.enc_key_salt) {
const refreshed = await tryRefreshToken();
if (refreshed) {
await chrome.storage.session.set({ enc_key_salt: local.enc_key_salt });
return 'unlock';
}
}
return false;
}
async function fetchAndDecryptVault() {
$('vault-spinner').classList.remove('hidden');
try {
const res = await apiFetch('/api/vault');
if (!res) return;
const raw = await res.json();
_items = await Promise.all(raw.map(async item => {
try {
const plain = await ExtCrypto.decryptItem(_vaultKey, item.enc_data, item.iv);
return { ...item, plain };
} catch { return { ...item, plain: null }; }
}));
// Write to session for the popup's own use (badge, rendering).
await chrome.storage.session.set({ vault_items: _items });
// Write a lightweight copy to local storage — this is what content scripts
// read, since chrome.storage.local works reliably across all Chrome versions
// and does not require message delivery from the background service worker.
const itemsForContentScript = _items.map(item => ({
id: item.id,
name: item.name,
item_type: item.item_type,
plain: item.plain,
}));
await chrome.storage.local.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');
}
}
// ── Rendering ─────────────────────────────────────────────────────────────────
function getTabItems() {
const q = $('vault-search').value.trim().toLowerCase();
let items = _items;
// Text filter
if (q) {
items = items.filter(i =>
i.name.toLowerCase().includes(q) ||
(i.plain?.username || '').toLowerCase().includes(q) ||
(i.plain?.url || '').toLowerCase().includes(q)
);
}
if (_activeTab === 'relevant') {
// Show ONLY items that match the current domain, sorted by name.
items = items.filter(isMatch).sort((a, b) => a.name.localeCompare(b.name));
} else if (_activeTab === 'recents') {
items = [...items].sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at)).slice(0, 20);
} else {
items = [...items].sort((a, b) => a.name.localeCompare(b.name));
}
return items;
}
function renderList() {
const listEl = $('vault-list');
const emptyEl = $('vault-empty');
const items = getTabItems();
if (!items.length) {
listEl.innerHTML = '';
if (_activeTab === 'relevant') {
const host = currentHostname();
emptyEl.textContent = host
? `No saved passwords for ${host}.`
: 'No items match this page.';
} else {
emptyEl.textContent = 'No items found.';
}
emptyEl.classList.remove('hidden');
return;
}
emptyEl.classList.add('hidden');
// SVG icons for action buttons
const svgCopy = `<svg viewBox="0 0 24 24" fill="none"><rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke="currentColor" stroke-width="1.7"/></svg>`;
const svgDots = `<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>`;
const svgFill = `<svg viewBox="0 0 24 24" fill="none"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M17 3l4 4-9 9H8v-4l9-9z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>`;
listEl.innerHTML = items.map(item => {
const matched = isMatch(item);
const site = escHtml(siteLabel(item));
const name = escHtml(item.name);
const badge = matched ? '<span class="badge-match">match</span>' : '';
const color = avatarColor(item.name);
const emoji = itemEmoji(item.item_type);
const canFill = item.item_type === 'password' && item.plain?.username && item.plain?.password;
const canCopy = item.item_type === 'password' && item.plain?.password;
return `<div class="vault-item" data-id="${item.id}">
<div class="item-avatar ${color}">${emoji}</div>
<div class="item-info">
<div class="item-site">${site}${badge}</div>
<div class="item-name">${name}</div>
</div>
<div class="item-actions">
${canCopy ? `<button class="btn-item-action" data-copy-pass="${item.id}" title="Copy password">${svgCopy}</button>` : ''}
${canFill ? `<button class="btn-item-action" data-autofill="${item.id}" title="Autofill">${svgFill}</button>` : ''}
<button class="btn-item-action" data-menu="${item.id}" title="More">${svgDots}</button>
</div>
</div>`;
}).join('');
// Copy password
listEl.querySelectorAll('[data-copy-pass]').forEach(btn =>
btn.addEventListener('click', e => {
e.stopPropagation();
const item = _items.find(i => i.id === parseInt(btn.dataset.copyPass));
if (item?.plain?.password) {
navigator.clipboard.writeText(item.plain.password);
btn.title = 'Copied!';
setTimeout(() => { btn.title = 'Copy password'; }, 1500);
}
})
);
// Autofill
listEl.querySelectorAll('[data-autofill]').forEach(btn =>
btn.addEventListener('click', async e => {
e.stopPropagation();
const item = _items.find(i => i.id === parseInt(btn.dataset.autofill));
if (!item?.plain) return;
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab?.id) {
chrome.tabs.sendMessage(tab.id, {
type: 'DO_AUTOFILL',
username: item.plain.username || '',
password: item.plain.password || '',
}).catch(() => { });
}
window.close();
})
);
// Three-dot menu: copy username
listEl.querySelectorAll('[data-menu]').forEach(btn =>
btn.addEventListener('click', e => {
e.stopPropagation();
const item = _items.find(i => i.id === parseInt(btn.dataset.menu));
if (!item?.plain) return;
// Simple: copy username on dots click (could be a dropdown in future)
if (item.plain.username) {
navigator.clipboard.writeText(item.plain.username);
btn.title = 'Username copied!';
setTimeout(() => { btn.title = 'More'; }, 1500);
}
})
);
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
function initTabs() {
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
_activeTab = btn.dataset.tab;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b === btn));
renderList();
});
});
}
// ── Save-prompt modal ─────────────────────────────────────────────────────────
/**
* 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.
$('btn-save-yes').onclick = async () => {
$('save-prompt-overlay').classList.add('hidden');
console.log('[PassKeeper] User saved credential for', pending_save.siteName);
await saveCredential(pending_save);
await chrome.storage.local.remove('pending_save');
};
$('btn-save-no').onclick = async () => {
$('save-prompt-overlay').classList.add('hidden');
console.log('[PassKeeper] User dismissed save prompt for', pending_save.siteName);
await chrome.storage.local.remove('pending_save');
};
}
async function saveCredential(data) {
if (!_vaultKey) return;
const plain = { url: data.url || '', username: data.username || '', password: data.password || '', notes: '' };
const name = $('save-name').value.trim() || data.siteName || 'Untitled';
// 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);
try {
const res = await apiFetch('/api/vault', {
method: 'POST',
body: JSON.stringify({ name, item_type: 'password', folder_id, enc_data, iv }),
});
if (res?.ok) {
console.log('[PassKeeper] Credential saved to vault, 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: '!@#$%^&*()-_=+[]{}|;:,.<>?',
};
function generatePassword(length, useLower, useUpper, useNumbers, useSymbols) {
// Always fall back to lower if nothing selected, preventing infinite loop.
const pool = [
useLower ? GEN_SETS.lower : '',
useUpper ? GEN_SETS.upper : '',
useNumbers ? GEN_SETS.numbers : '',
useSymbols ? GEN_SETS.symbols : '',
].join('');
if (!pool) return '';
// Guarantee at least one character from each selected charset.
const required = [];
if (useLower) required.push(GEN_SETS.lower[Math.floor(Math.random() * GEN_SETS.lower.length)]);
if (useUpper) required.push(GEN_SETS.upper[Math.floor(Math.random() * GEN_SETS.upper.length)]);
if (useNumbers) required.push(GEN_SETS.numbers[Math.floor(Math.random() * GEN_SETS.numbers.length)]);
if (useSymbols) required.push(GEN_SETS.symbols[Math.floor(Math.random() * GEN_SETS.symbols.length)]);
const arr = new Uint32Array(length);
crypto.getRandomValues(arr);
const rest = Array.from(arr).map(n => pool[n % pool.length]);
// Splice required chars into random positions and trim to length.
const combined = [...rest];
required.forEach((ch, i) => { combined[i] = ch; });
// Fisher-Yates shuffle for uniform distribution.
for (let i = combined.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[combined[i], combined[j]] = [combined[j], combined[i]];
}
return combined.slice(0, length).join('');
}
function passwordStrength(pw) {
if (!pw) return { label: '', color: '' };
let score = 0;
if (pw.length >= 8) score++;
if (pw.length >= 12) score++;
if (pw.length >= 16) score++;
if (/[a-z]/.test(pw)) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^a-zA-Z0-9]/.test(pw)) score++;
if (score <= 2) return { label: 'Weak', color: '#dc2626' };
if (score <= 4) return { label: 'Fair', color: '#d97706' };
if (score <= 5) return { label: 'Good', color: '#2563eb' };
if (score <= 6) return { label: 'Strong', color: '#16a34a' };
return { label: 'Very Strong', color: '#15803d' };
}
let _genInitialised = false;
function initGenerator() {
// Only bind events once — subsequent calls just re-generate a password.
if (!_genInitialised) {
_genInitialised = true;
const slider = $('gen-length');
const numInput = $('gen-length-num');
// Keep slider and number input in sync.
slider.addEventListener('input', () => {
numInput.value = slider.value;
refreshGen();
});
numInput.addEventListener('input', () => {
const v = Math.min(64, Math.max(8, parseInt(numInput.value) || 16));
slider.value = v;
numInput.value = v;
refreshGen();
});
['gen-lower', 'gen-upper', 'gen-numbers', 'gen-symbols'].forEach(id => {
$(id).addEventListener('change', refreshGen);
});
$('btn-gen-refresh').addEventListener('click', refreshGen);
$('btn-gen-copy').addEventListener('click', () => {
const pw = $('gen-output').textContent;
if (!pw || pw === 'Click refresh to generate') return;
navigator.clipboard.writeText(pw).then(() => {
$('btn-gen-copy').title = 'Copied!';
setTimeout(() => { $('btn-gen-copy').title = 'Copy password'; }, 1500);
});
});
}
// Always generate a fresh password when the view opens.
refreshGen();
}
function refreshGen() {
const length = Math.min(64, Math.max(8, parseInt($('gen-length').value) || 16));
const useLower = $('gen-lower').checked;
const useUpper = $('gen-upper').checked;
const useNumbers = $('gen-numbers').checked;
const useSymbols = $('gen-symbols').checked;
// Require at least one charset.
if (!useLower && !useUpper && !useNumbers && !useSymbols) {
$('gen-output').textContent = 'Select at least one character type';
$('gen-strength-label').textContent = '';
return;
}
const pw = generatePassword(length, useLower, useUpper, useNumbers, useSymbols);
$('gen-output').textContent = pw;
const { label, color } = passwordStrength(pw);
const strengthEl = $('gen-strength-label');
strengthEl.textContent = label;
strengthEl.style.color = color;
}
// ── 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 res = await apiFetch('/api/vault', {
method: 'POST',
body: JSON.stringify({ name, item_type: 'password', folder_id, enc_data, iv }),
});
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);
// 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);