From 6db32c6a00a11d6f2ed3bb6fd6cbd6d76f094f4a Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Sat, 2 May 2026 19:25:17 -0400 Subject: [PATCH] 05/02/2026 updated code for security 3 --- app/static/js/auth.js | 235 ++++++--- app/static/js/vault.js | 764 +++++++++++++++++++-------- app/templates/auth/login.html | 105 +++- app/templates/vault/index.html | 12 +- extension/popup/popup.html | 915 ++++++++++++++++++++++----------- extension/popup/popup.js | 53 +- 6 files changed, 1451 insertions(+), 633 deletions(-) diff --git a/app/static/js/auth.js b/app/static/js/auth.js index 38cbfd4..46c2092 100644 --- a/app/static/js/auth.js +++ b/app/static/js/auth.js @@ -10,23 +10,25 @@ const Auth = (() => { function csrfToken() { const meta = document.querySelector('meta[name="csrf-token"]'); - return meta ? meta.content : ''; + return meta ? meta.content : ""; } function showError(formEl, message) { - let el = formEl.querySelector('.form-error'); + let el = formEl.querySelector(".form-error"); if (!el) { - el = document.createElement('p'); - el.className = 'form-error'; + el = document.createElement("p"); + el.className = "form-error"; formEl.prepend(el); } el.textContent = message; - el.classList.remove('hidden'); + el.classList.remove("hidden"); } function setLoading(btn, loading) { btn.disabled = loading; - btn.textContent = loading ? btn.dataset.loadingText || 'Please wait…' : btn.dataset.originalText; + btn.textContent = loading + ? btn.dataset.loadingText || "Please wait…" + : btn.dataset.originalText; } // ── Register ───────────────────────────────────────────────────────────── @@ -41,24 +43,36 @@ const Auth = (() => { const password = form.password.value; const confirm = form.confirm_password.value; - if (password !== confirm) { showError(form, 'Passwords do not match.'); return; } - if (password.length < 12) { showError(form, 'Master password must be at least 12 characters.'); return; } + if (password !== confirm) { + showError(form, "Passwords do not match."); + return; + } + if (password.length < 12) { + showError(form, "Master password must be at least 12 characters."); + return; + } setLoading(btn, true); try { const authHash = await Crypto.deriveAuthHash(password, email); const enc_key_salt = Crypto.generateSalt(16); - const res = await fetch('/api/auth/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() }, + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken(), + }, body: JSON.stringify({ email, auth_hash: authHash, enc_key_salt }), }); const data = await res.json(); - if (!res.ok) { showError(form, data.error || 'Registration failed.'); return; } - window.location.href = '/login?registered=1'; + if (!res.ok) { + showError(form, data.error || "Registration failed."); + return; + } + window.location.href = "/login?registered=1"; } catch (err) { - showError(form, 'An unexpected error occurred. Please try again.'); + showError(form, "An unexpected error occurred. Please try again."); console.error(err); } finally { setLoading(btn, false); @@ -85,27 +99,33 @@ const Auth = (() => { try { const authHash = await Crypto.deriveAuthHash(password, email); - const res = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() }, + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken(), + }, body: JSON.stringify({ email, auth_hash: authHash }), }); const data = await res.json(); - if (!res.ok) { showError(form, data.error || 'Invalid email or password.'); return; } + if (!res.ok) { + showError(form, data.error || "Invalid email or password."); + return; + } if (data.mfa_required) { // Step 2: collect TOTP code _pendingMfaToken = data.mfa_token; _pendingEncKeySalt = data.enc_key_salt; _pendingPassword = password; - sessionStorage.setItem('enc_key_salt', data.enc_key_salt); + sessionStorage.setItem("enc_key_salt", data.enc_key_salt); showMfaStep(); return; } await completeLogin(password, data); } catch (err) { - showError(form, 'An unexpected error occurred. Please try again.'); + showError(form, "An unexpected error occurred. Please try again."); console.error(err); } finally { setLoading(btn, false); @@ -117,21 +137,48 @@ const Auth = (() => { const form = e.target; const btn = form.querySelector('[type="submit"]'); btn.dataset.originalText = btn.textContent; - const errEl = document.getElementById('mfa-error'); - errEl.classList.add('hidden'); + const errEl = document.getElementById("mfa-error"); + errEl.classList.add("hidden"); - const totp_code = document.getElementById('mfa-code').value.trim(); - if (!totp_code) return; + const usingBackup = !document + .getElementById("mfa-backup-section") + .classList.contains("hidden"); + const body = { mfa_token: _pendingMfaToken }; + + if (usingBackup) { + const code = document + .getElementById("mfa-backup-code") + .value.trim() + .toLowerCase() + .replace(/[\s-]/g, ""); + if (!code) { + errEl.textContent = "Enter your backup code."; + errEl.classList.remove("hidden"); + return; + } + body.backup_code = code; + } else { + const totp_code = document.getElementById("mfa-code").value.trim(); + if (!totp_code) return; + body.totp_code = totp_code; + } setLoading(btn, true); try { - const res = await fetch('/api/auth/mfa/verify', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() }, - body: JSON.stringify({ mfa_token: _pendingMfaToken, totp_code }), + const res = await fetch("/api/auth/mfa/verify", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken(), + }, + body: JSON.stringify(body), }); const data = await res.json(); - if (!res.ok) { errEl.classList.remove('hidden'); return; } + if (!res.ok) { + errEl.textContent = data.error || "Invalid code. Try again."; + errEl.classList.remove("hidden"); + return; + } await completeLogin(_pendingPassword, { access_token: data.access_token, @@ -139,7 +186,8 @@ const Auth = (() => { enc_key_salt: _pendingEncKeySalt, }); } catch (err) { - errEl.classList.remove('hidden'); + errEl.textContent = "An unexpected error occurred. Please try again."; + errEl.classList.remove("hidden"); console.error(err); } finally { setLoading(btn, false); @@ -147,36 +195,38 @@ const Auth = (() => { } async function completeLogin(password, data) { - sessionStorage.setItem('access_token', data.access_token); - localStorage.setItem('refresh_token', data.refresh_token); - sessionStorage.setItem('enc_key_salt', data.enc_key_salt); + sessionStorage.setItem("access_token", data.access_token); + localStorage.setItem("refresh_token", data.refresh_token); + sessionStorage.setItem("enc_key_salt", data.enc_key_salt); // Notify the browser extension (if installed) so it can share the session - window.dispatchEvent(new CustomEvent('passkeeper:session', { - detail: { - access_token: data.access_token, - refresh_token: data.refresh_token, - enc_key_salt: data.enc_key_salt, - }, - })); + window.dispatchEvent( + new CustomEvent("passkeeper:session", { + detail: { + access_token: data.access_token, + refresh_token: data.refresh_token, + enc_key_salt: data.enc_key_salt, + }, + }), + ); const vaultKey = await Crypto.deriveVaultKey(password, data.enc_key_salt); VaultSession.setKey(vaultKey); - window.location.href = '/vault'; + window.location.href = "/vault"; } function showMfaStep() { - document.getElementById('login-step-1').classList.add('hidden'); - document.getElementById('login-step-2').classList.remove('hidden'); - document.getElementById('mfa-code').focus(); + document.getElementById("login-step-1").classList.add("hidden"); + document.getElementById("login-step-2").classList.remove("hidden"); + document.getElementById("mfa-code").focus(); } function hideMfaStep() { - document.getElementById('login-step-2').classList.add('hidden'); - document.getElementById('login-step-1').classList.remove('hidden'); - document.getElementById('mfa-code').value = ''; - document.getElementById('mfa-error').classList.add('hidden'); + document.getElementById("login-step-2").classList.add("hidden"); + document.getElementById("login-step-1").classList.remove("hidden"); + document.getElementById("mfa-code").value = ""; + document.getElementById("mfa-error").classList.add("hidden"); _pendingMfaToken = null; _pendingEncKeySalt = null; _pendingPassword = null; @@ -188,17 +238,17 @@ const Auth = (() => { const btn = document.getElementById(btnId); const input = document.getElementById(inputId); if (btn && input) { - btn.addEventListener('click', () => { - input.type = input.type === 'password' ? 'text' : 'password'; + btn.addEventListener("click", () => { + input.type = input.type === "password" ? "text" : "password"; }); } } function initStrengthMeter() { - const input = document.getElementById('password'); - const bar = document.getElementById('strength-bar'); + const input = document.getElementById("password"); + const bar = document.getElementById("strength-bar"); if (!input || !bar) return; - input.addEventListener('input', function () { + input.addEventListener("input", function () { const v = this.value; let score = 0; if (v.length >= 12) score++; @@ -206,45 +256,84 @@ const Auth = (() => { if (/[A-Z]/.test(v)) score++; if (/[0-9]/.test(v)) score++; if (/[^A-Za-z0-9]/.test(v)) score++; - const labels = ['', 'Very weak', 'Weak', 'Fair', 'Strong', 'Very strong']; - const classes = ['', 'strength-1', 'strength-2', 'strength-3', 'strength-4', 'strength-5']; - bar.textContent = v ? labels[score] : ''; - bar.className = 'password-strength ' + (v ? classes[score] : ''); + const labels = ["", "Very weak", "Weak", "Fair", "Strong", "Very strong"]; + const classes = [ + "", + "strength-1", + "strength-2", + "strength-3", + "strength-4", + "strength-5", + ]; + bar.textContent = v ? labels[score] : ""; + bar.className = "password-strength " + (v ? classes[score] : ""); }); } function init() { - const registerForm = document.getElementById('register-form'); - if (registerForm) registerForm.addEventListener('submit', handleRegister); + const registerForm = document.getElementById("register-form"); + if (registerForm) registerForm.addEventListener("submit", handleRegister); - const loginForm = document.getElementById('login-form'); - if (loginForm) loginForm.addEventListener('submit', handleLogin); + const loginForm = document.getElementById("login-form"); + if (loginForm) loginForm.addEventListener("submit", handleLogin); - const mfaForm = document.getElementById('mfa-form'); - if (mfaForm) mfaForm.addEventListener('submit', handleMfaVerify); + const mfaForm = document.getElementById("mfa-form"); + if (mfaForm) mfaForm.addEventListener("submit", handleMfaVerify); - document.getElementById('btn-back-to-password')?.addEventListener('click', hideMfaStep); + document + .getElementById("btn-back-to-password") + ?.addEventListener("click", hideMfaStep); - initPasswordToggle('toggle-login-pass', 'password'); - initPasswordToggle('toggle-reg-pass', 'password'); + document + .getElementById("btn-use-backup-code") + ?.addEventListener("click", () => { + document.getElementById("mfa-totp-section").classList.add("hidden"); + document + .getElementById("mfa-backup-section") + .classList.remove("hidden"); + document.getElementById("btn-use-backup-code").classList.add("hidden"); + document.getElementById("btn-use-totp-code").classList.remove("hidden"); + document.getElementById("mfa-backup-code").focus(); + }); + + document + .getElementById("btn-use-totp-code") + ?.addEventListener("click", () => { + document.getElementById("mfa-backup-section").classList.add("hidden"); + document.getElementById("mfa-totp-section").classList.remove("hidden"); + document.getElementById("btn-use-totp-code").classList.add("hidden"); + document + .getElementById("btn-use-backup-code") + .classList.remove("hidden"); + document.getElementById("mfa-code").focus(); + }); + + initPasswordToggle("toggle-login-pass", "password"); + initPasswordToggle("toggle-reg-pass", "password"); initStrengthMeter(); - if (window.location.search.includes('registered=1')) { - const notice = document.getElementById('register-notice'); - if (notice) notice.classList.remove('hidden'); + if (window.location.search.includes("registered=1")) { + const notice = document.getElementById("register-notice"); + if (notice) notice.classList.remove("hidden"); } } return { init }; })(); -document.addEventListener('DOMContentLoaded', Auth.init); +document.addEventListener("DOMContentLoaded", Auth.init); // ── VaultSession — holds the vault key for the lifetime of the browser tab ── const VaultSession = (() => { let _vaultKey = null; - function setKey(key) { _vaultKey = key; } - function getKey() { return _vaultKey; } - function clear() { _vaultKey = null; } + function setKey(key) { + _vaultKey = key; + } + function getKey() { + return _vaultKey; + } + function clear() { + _vaultKey = null; + } return { setKey, getKey, clear }; })(); diff --git a/app/static/js/vault.js b/app/static/js/vault.js index e162464..9e8ec27 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -15,7 +15,7 @@ const Vault = (() => { // Account Settings. Zero means never. Stored in localStorage so it persists // across page reloads without a server round-trip. - const WEB_IDLE_KEY = 'web_idle_minutes'; + const WEB_IDLE_KEY = "web_idle_minutes"; const WEB_IDLE_DEFAULT = 15; // minutes; 0 = never let _webIdleTimer = null; @@ -31,17 +31,25 @@ const Vault = (() => { _webIdleTimer = setTimeout(() => { // Only lock if the vault is currently unlocked. if (!VaultSession.getKey()) return; - console.log('[PassKeeper] Web inactivity lock after', mins, 'min'); + console.log("[PassKeeper] Web inactivity lock after", mins, "min"); VaultSession.clear(); showUnlockOverlay(); - showToast('Vault locked due to inactivity.', 'info'); + showToast("Vault locked due to inactivity.", "info"); }, mins * 60_000); } function _startWebIdleTracking() { - const events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll']; + const events = [ + "mousemove", + "mousedown", + "keydown", + "touchstart", + "scroll", + ]; const handler = () => _resetWebIdleTimer(); - events.forEach((e) => document.addEventListener(e, handler, { passive: true })); + events.forEach((e) => + document.addEventListener(e, handler, { passive: true }), + ); _resetWebIdleTimer(); } let _activeFilter = null; @@ -200,7 +208,11 @@ const Vault = (() => { // 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 Crypto.decryptName(vaultKey, item.enc_name, item.iv_name); + const decrypted = await Crypto.decryptName( + vaultKey, + item.enc_name, + item.iv_name, + ); if (decrypted) displayName = decrypted; } return { ...item, name: displayName, plain }; @@ -235,11 +247,13 @@ const Vault = (() => { function switchView(view, { pushState = true } = {}) { _currentView = view; - ["vault", "security", "sharing", "emergency", "import-export"].forEach((v) => { - document - .getElementById(`view-${v}`) - ?.classList.toggle("hidden", v !== view); - }); + ["vault", "security", "sharing", "emergency", "import-export"].forEach( + (v) => { + document + .getElementById(`view-${v}`) + ?.classList.toggle("hidden", v !== view); + }, + ); document.querySelectorAll(".sidebar-item[data-view]").forEach((el) => { el.classList.toggle("active", el.dataset.view === view); }); @@ -329,40 +343,49 @@ const Vault = (() => { /** Parse a comma-separated tag string into a sorted, deduped lowercase array. */ function _parseTags(str) { - return [...new Set( - (str || '').split(',').map(t => t.trim().toLowerCase()).filter(Boolean) - )].sort(); + return [ + ...new Set( + (str || "") + .split(",") + .map((t) => t.trim().toLowerCase()) + .filter(Boolean), + ), + ].sort(); } /** Collect all unique tags across all loaded items. */ function _allTags() { const set = new Set(); - _items.forEach(i => (i.plain?.tags || []).forEach(t => set.add(t))); + _items.forEach((i) => (i.plain?.tags || []).forEach((t) => set.add(t))); return [...set].sort(); } /** Render the tags sidebar list. */ function renderTagList() { - const ul = document.getElementById('sidebar-tags'); + const ul = document.getElementById("sidebar-tags"); if (!ul) return; - ul.innerHTML = ''; + ul.innerHTML = ""; const tags = _allTags(); if (!tags.length) { ul.innerHTML = ''; return; } - tags.forEach(tag => { - const li = document.createElement('li'); - li.className = 'sidebar-item sidebar-tag-item'; + tags.forEach((tag) => { + const li = document.createElement("li"); + li.className = "sidebar-item sidebar-tag-item"; li.dataset.tag = tag; li.innerHTML = `🏷️${escHtml(tag)}`; - li.addEventListener('click', () => { - document.querySelectorAll('.sidebar-item').forEach(el => el.classList.remove('active')); - li.classList.add('active'); - _activeFilter = { type: 'tag', value: tag }; - switchView('vault'); - updateVaultTitle('#' + tag); - renderItemList(_items.filter(i => (i.plain?.tags || []).includes(tag))); + li.addEventListener("click", () => { + document + .querySelectorAll(".sidebar-item") + .forEach((el) => el.classList.remove("active")); + li.classList.add("active"); + _activeFilter = { type: "tag", value: tag }; + switchView("vault"); + updateVaultTitle("#" + tag); + renderItemList( + _items.filter((i) => (i.plain?.tags || []).includes(tag)), + ); }); ul.appendChild(li); }); @@ -406,12 +429,13 @@ const Vault = (() => { // ── Collapsible group header ──────────────────────────────────────────── const header = document.createElement("li"); - header.className = "vault-group-header" + (isCollapsed ? " collapsed" : ""); + header.className = + "vault-group-header" + (isCollapsed ? " collapsed" : ""); header.innerHTML = `${escHtml(groupName)}` + `` + - `${count}` + - `${isCollapsed ? '▶' : '▼'}` + + `${count}` + + `${isCollapsed ? "▶" : "▼"}` + ``; header.addEventListener("click", () => { if (_collapsedGroups.has(groupName)) { @@ -487,16 +511,16 @@ const Vault = (() => { item.item_type === "password" && !!extractTotpSecret(item.plain?.totp_uri); - const tagBadges = itemTags.map(t => - `${escHtml(t)}` - ).join(''); + const tagBadges = itemTags + .map((t) => `${escHtml(t)}`) + .join(""); li.innerHTML = `
${icon}
${escHtml(item.name)} ${escHtml(subText)} - ${tagBadges ? `
${tagBadges}
` : ''} + ${tagBadges ? `
${tagBadges}
` : ""} ${showTotp ? `······` : ""}
@@ -604,21 +628,24 @@ const Vault = (() => { async function checkHibp(password) { try { const msgBuffer = new TextEncoder().encode(password); - const hashBuffer = await crypto.subtle.digest('SHA-1', msgBuffer); + const hashBuffer = await crypto.subtle.digest("SHA-1", msgBuffer); const hashHex = Array.from(new Uint8Array(hashBuffer)) - .map(b => b.toString(16).padStart(2, '0')) - .join('') + .map((b) => b.toString(16).padStart(2, "0")) + .join("") .toUpperCase(); const prefix = hashHex.slice(0, 5); const suffix = hashHex.slice(5); - const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, { - headers: { 'Add-Padding': 'true' }, - }); + const res = await fetch( + `https://api.pwnedpasswords.com/range/${prefix}`, + { + headers: { "Add-Padding": "true" }, + }, + ); if (!res.ok) return 0; const text = await res.text(); - const line = text.split('\n').find(l => l.startsWith(suffix)); + const line = text.split("\n").find((l) => l.startsWith(suffix)); if (!line) return 0; - return parseInt(line.split(':')[1], 10) || 0; + return parseInt(line.split(":")[1], 10) || 0; } catch { return 0; // network error or API unavailable — fail safe (do not block UI) } @@ -675,8 +702,7 @@ const Vault = (() => { ]); const old = pwItems.filter( (i) => - new Date(i.created_at).getTime() < cutoff && - weakOrReusedIds.has(i.id), + new Date(i.created_at).getTime() < cutoff && weakOrReusedIds.has(i.id), ); const total = pwItems.length; @@ -754,19 +780,24 @@ const Vault = (() => { .filter(Boolean), "Same password used on multiple sites.", ); - makeSection("Old Passwords", "🕐", old, "Weak or reused passwords not changed in over 180 days."); + makeSection( + "Old Passwords", + "🕐", + old, + "Weak or reused passwords not changed in over 180 days.", + ); // ── Missing 2FA warning ────────────────────────────────────────────────── // Flag password items that have a URL but no TOTP URI saved. // These accounts likely support 2FA but the user hasn't stored it. - const noTotp = pwItems.filter((i) => - i.plain?.url && !extractTotpSecret(i.plain?.totp_uri) + const noTotp = pwItems.filter( + (i) => i.plain?.url && !extractTotpSecret(i.plain?.totp_uri), ); makeSection( - 'No 2FA Saved', - '🔓', + "No 2FA Saved", + "🔓", noTotp, - 'These accounts may support two-factor authentication but have no TOTP code stored.', + "These accounts may support two-factor authentication but have no TOTP code stored.", ); // ── HaveIBeenPwned breach check ────────────────────────────────────────── @@ -789,9 +820,9 @@ const Vault = (() => { pwItems.map(async (item) => ({ item, count: await checkHibp(item.plain.password), - })) + })), ); - const breached = hibpResults.filter(r => r.count > 0).map(r => r.item); + const breached = hibpResults.filter((r) => r.count > 0).map((r) => r.item); if (!breached.length) { hibpSection.innerHTML = ` @@ -812,19 +843,27 @@ const Vault = (() => {
`; hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => { btn.addEventListener("click", () => { - const item = _items.find((it) => it.id === parseInt(btn.dataset.secEdit)); - if (item) { switchView("vault"); openModal("edit", item); } + const item = _items.find( + (it) => it.id === parseInt(btn.dataset.secEdit), + ); + if (item) { + switchView("vault"); + openModal("edit", item); + } }); }); } @@ -845,7 +884,9 @@ const Vault = (() => { function _parseCsvImport(text) { const lines = text.split(/\r?\n/); if (lines.length < 2) return []; - const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, '').toLowerCase()); + const headers = lines[0] + .split(",") + .map((h) => h.trim().replace(/^"|"$/g, "").toLowerCase()); // Detect format by inspecting header names. const col = (candidates) => { @@ -855,11 +896,11 @@ const Vault = (() => { } return -1; }; - const iName = col(['name', 'title']); - const iUrl = col(['url', 'login_uri']); - const iUser = col(['username', 'login_username']); - const iPass = col(['password', 'login_password']); - const iNotes = col(['notes', 'note']); + const iName = col(["name", "title"]); + const iUrl = col(["url", "login_uri"]); + const iUser = col(["username", "login_username"]); + const iPass = col(["password", "login_password"]); + const iNotes = col(["notes", "note"]); const rows = []; for (let i = 1; i < lines.length; i++) { @@ -867,13 +908,22 @@ const Vault = (() => { if (!line) continue; // Simple CSV split — handles quoted fields containing commas. const cells = []; - let cur = '', inQuote = false; - for (const ch of line + ',') { - if (ch === '"') { inQuote = !inQuote; } - else if (ch === ',' && !inQuote) { cells.push(cur.trim()); cur = ''; } - else { cur += ch; } + let cur = "", + inQuote = false; + for (const ch of line + ",") { + if (ch === '"') { + inQuote = !inQuote; + } else if (ch === "," && !inQuote) { + cells.push(cur.trim()); + cur = ""; + } else { + cur += ch; + } } - const get = (idx) => (idx !== -1 && cells[idx] != null ? cells[idx].replace(/^"|"$/g, '') : ''); + const get = (idx) => + idx !== -1 && cells[idx] != null + ? cells[idx].replace(/^"|"$/g, "") + : ""; const name = get(iName); const password = get(iPass); if (!name || !password) continue; @@ -896,38 +946,54 @@ const Vault = (() => { // ── Export ─────────────────────────────────────────────────────────────── - document.getElementById('btn-export-json')?.addEventListener('click', async () => { - try { - const res = await apiFetch('/api/vault'); - if (!res) return; - const items = await res.json(); - const payload = JSON.stringify({ version: 1, exported_at: new Date().toISOString(), items }, null, 2); - const blob = new Blob([payload], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `passkeeper-export-${new Date().toISOString().slice(0,10)}.json`; - a.click(); - URL.revokeObjectURL(url); - showToast('Encrypted vault exported.'); - console.log('[PassKeeper] Vault exported:', items.length, 'items'); - } catch (err) { - showToast('Export failed: ' + err.message, 'error'); - } - }); + document + .getElementById("btn-export-json") + ?.addEventListener("click", async () => { + try { + const res = await apiFetch("/api/vault"); + if (!res) return; + const items = await res.json(); + const payload = JSON.stringify( + { version: 1, exported_at: new Date().toISOString(), items }, + null, + 2, + ); + const blob = new Blob([payload], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `passkeeper-export-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + showToast("Encrypted vault exported."); + console.log("[PassKeeper] Vault exported:", items.length, "items"); + } catch (err) { + showToast("Export failed: " + err.message, "error"); + } + }); - document.getElementById('btn-export-csv')?.addEventListener('click', async () => { - const vaultKey = VaultSession.getKey(); - if (!vaultKey) { showUnlockOverlay(); return; } + document + .getElementById("btn-export-csv") + ?.addEventListener("click", async () => { + const vaultKey = VaultSession.getKey(); + if (!vaultKey) { + showUnlockOverlay(); + return; + } - // Warn the user that this export contains plaintext passwords before proceeding. - const confirmed = await new Promise((resolve) => { - const overlay = document.createElement('div'); - overlay.style.cssText = [ - 'position:fixed', 'inset:0', 'background:rgba(0,0,0,0.55)', - 'z-index:9999', 'display:flex', 'align-items:center', 'justify-content:center', - ].join(';'); - overlay.innerHTML = ` + // Warn the user that this export contains plaintext passwords before proceeding. + const confirmed = await new Promise((resolve) => { + const overlay = document.createElement("div"); + overlay.style.cssText = [ + "position:fixed", + "inset:0", + "background:rgba(0,0,0,0.55)", + "z-index:9999", + "display:flex", + "align-items:center", + "justify-content:center", + ].join(";"); + overlay.innerHTML = `
@@ -949,149 +1015,219 @@ const Vault = (() => {
`; - document.body.appendChild(overlay); - overlay.querySelector('#_csv_cancel').addEventListener('click', () => { overlay.remove(); resolve(false); }); - overlay.querySelector('#_csv_confirm').addEventListener('click', () => { overlay.remove(); resolve(true); }); - overlay.addEventListener('click', (e) => { if (e.target === overlay) { overlay.remove(); resolve(false); } }); - }); - - if (!confirmed) return; - - try { - const res = await apiFetch('/api/vault'); - if (!res) return; - const raw = await res.json(); - const decrypted = await Promise.all(raw.map(async (item) => { - try { - const plain = await Crypto.decryptItem(vaultKey, item.enc_data, item.iv); - let displayName = item.name; - if (item.enc_name && item.iv_name) { - const n = await Crypto.decryptName(vaultKey, item.enc_name, item.iv_name); - if (n) displayName = n; + document.body.appendChild(overlay); + overlay + .querySelector("#_csv_cancel") + .addEventListener("click", () => { + overlay.remove(); + resolve(false); + }); + overlay + .querySelector("#_csv_confirm") + .addEventListener("click", () => { + overlay.remove(); + resolve(true); + }); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) { + overlay.remove(); + resolve(false); } - return { name: displayName, ...plain }; - } catch { return null; } - })); - const csvRows = [['name','url','username','password','notes']]; - decrypted.filter(Boolean).forEach((r) => { - if (r.password) { - const esc = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`; - csvRows.push([r.name, r.url, r.username, r.password, r.notes].map(esc)); - } + }); }); - const csv = csvRows.map((r) => r.join(',')).join('\n'); - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `passkeeper-export-${new Date().toISOString().slice(0,10)}.csv`; - a.click(); - URL.revokeObjectURL(url); - showToast('CSV exported — store it securely.'); - console.log('[PassKeeper] CSV export:', decrypted.filter(Boolean).length, 'items'); - } catch (err) { - showToast('CSV export failed: ' + err.message, 'error'); - } - }); + + if (!confirmed) return; + + try { + const res = await apiFetch("/api/vault"); + if (!res) return; + const raw = await res.json(); + const decrypted = await Promise.all( + raw.map(async (item) => { + try { + const plain = await Crypto.decryptItem( + vaultKey, + item.enc_data, + item.iv, + ); + let displayName = item.name; + if (item.enc_name && item.iv_name) { + const n = await Crypto.decryptName( + vaultKey, + item.enc_name, + item.iv_name, + ); + if (n) displayName = n; + } + return { name: displayName, ...plain }; + } catch { + return null; + } + }), + ); + const csvRows = [["name", "url", "username", "password", "notes"]]; + decrypted.filter(Boolean).forEach((r) => { + if (r.password) { + const esc = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`; + csvRows.push( + [r.name, r.url, r.username, r.password, r.notes].map(esc), + ); + } + }); + const csv = csvRows.map((r) => r.join(",")).join("\n"); + const blob = new Blob([csv], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `passkeeper-export-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + showToast("CSV exported — store it securely."); + console.log( + "[PassKeeper] CSV export:", + decrypted.filter(Boolean).length, + "items", + ); + } catch (err) { + showToast("CSV export failed: " + err.message, "error"); + } + }); // ── Import ─────────────────────────────────────────────────────────────── - const fileInput = document.getElementById('import-file-input'); - const fileNameEl = document.getElementById('import-file-name'); - const previewEl = document.getElementById('import-preview'); - const confirmBtn = document.getElementById('btn-import-confirm'); - const resultEl = document.getElementById('import-result'); + const fileInput = document.getElementById("import-file-input"); + const fileNameEl = document.getElementById("import-file-name"); + const previewEl = document.getElementById("import-preview"); + const confirmBtn = document.getElementById("btn-import-confirm"); + const resultEl = document.getElementById("import-result"); - fileInput?.addEventListener('change', async () => { + fileInput?.addEventListener("change", async () => { const file = fileInput.files[0]; if (!file) return; fileNameEl.textContent = file.name; - previewEl.classList.add('hidden'); - resultEl.classList.add('hidden'); + previewEl.classList.add("hidden"); + resultEl.classList.add("hidden"); confirmBtn.disabled = true; _importRows = []; const text = await file.text(); - const isJson = file.name.endsWith('.json'); + const isJson = file.name.endsWith(".json"); try { if (isJson) { // PassKeeper encrypted JSON export. const parsed = JSON.parse(text); const items = parsed.items || (Array.isArray(parsed) ? parsed : []); - if (!items.length) throw new Error('No items found in JSON file.'); + if (!items.length) throw new Error("No items found in JSON file."); _importRows = items; previewEl.innerHTML = `

Found ${items.length} encrypted item(s) ready to import.

These are already encrypted with your vault key — they will be imported as-is.

`; } else { // CSV — decrypt and re-encrypt with current vault key. const vaultKey = VaultSession.getKey(); - if (!vaultKey) { showUnlockOverlay(); return; } + if (!vaultKey) { + showUnlockOverlay(); + return; + } const rows = _parseCsvImport(text); - if (!rows.length) throw new Error('No valid rows found. Check the CSV format.'); + if (!rows.length) + throw new Error("No valid rows found. Check the CSV format."); _importRows = rows; // stored as plaintext — encrypted on confirm previewEl.innerHTML = `

Found ${rows.length} password(s) to import.

Preview (first 5):

- - ${rows.length > 5 ? `

…and ${rows.length - 5} more.

` : ''}`; + + ${rows.length > 5 ? `

…and ${rows.length - 5} more.

` : ""}`; } - previewEl.classList.remove('hidden'); + previewEl.classList.remove("hidden"); confirmBtn.disabled = false; - confirmBtn.dataset.mode = isJson ? 'json' : 'csv'; + confirmBtn.dataset.mode = isJson ? "json" : "csv"; } catch (err) { previewEl.innerHTML = `

⚠️ ${escHtml(err.message)}

`; - previewEl.classList.remove('hidden'); + previewEl.classList.remove("hidden"); } }); - confirmBtn?.addEventListener('click', async () => { + confirmBtn?.addEventListener("click", async () => { const vaultKey = VaultSession.getKey(); - if (!vaultKey) { showUnlockOverlay(); return; } + if (!vaultKey) { + showUnlockOverlay(); + return; + } confirmBtn.disabled = true; - confirmBtn.textContent = 'Importing…'; - resultEl.classList.add('hidden'); + confirmBtn.textContent = "Importing…"; + resultEl.classList.add("hidden"); try { let payload; - if (confirmBtn.dataset.mode === 'json') { + if (confirmBtn.dataset.mode === "json") { // Already-encrypted items — send directly. payload = _importRows; } else { // Plaintext CSV rows — encrypt each one now. - payload = await Promise.all(_importRows.map(async (row) => { - const plain = { url: row.url || '', username: row.username || '', password: row.password, notes: row.notes || '' }; - const { enc_data, iv } = await Crypto.encryptItem(vaultKey, plain); - const { enc_name, iv_name } = await Crypto.encryptName(vaultKey, row.name); - return { name: 'password', item_type: 'password', enc_data, iv, enc_name, iv_name }; - })); + payload = await Promise.all( + _importRows.map(async (row) => { + const plain = { + url: row.url || "", + username: row.username || "", + password: row.password, + notes: row.notes || "", + }; + const { enc_data, iv } = await Crypto.encryptItem( + vaultKey, + plain, + ); + const { enc_name, iv_name } = await Crypto.encryptName( + vaultKey, + row.name, + ); + return { + name: "password", + item_type: "password", + enc_data, + iv, + enc_name, + iv_name, + }; + }), + ); } - const res = await apiFetch('/api/vault/import', { - method: 'POST', + const res = await apiFetch("/api/vault/import", { + method: "POST", body: JSON.stringify(payload), }); if (!res) return; const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Import failed.'); + if (!res.ok) throw new Error(data.error || "Import failed."); - resultEl.innerHTML = `✅ Imported ${data.imported} item(s)${data.skipped ? `, skipped ${data.skipped} malformed row(s)` : ''}.`; - resultEl.className = 'import-result import-result-ok'; - resultEl.classList.remove('hidden'); + resultEl.innerHTML = `✅ Imported ${data.imported} item(s)${data.skipped ? `, skipped ${data.skipped} malformed row(s)` : ""}.`; + resultEl.className = "import-result import-result-ok"; + resultEl.classList.remove("hidden"); _importRows = []; - confirmBtn.textContent = 'Import items'; - fileInput.value = ''; - fileNameEl.textContent = 'No file chosen'; - previewEl.classList.add('hidden'); - console.log('[PassKeeper] Import complete:', data.imported, 'imported,', data.skipped, 'skipped'); + confirmBtn.textContent = "Import items"; + fileInput.value = ""; + fileNameEl.textContent = "No file chosen"; + previewEl.classList.add("hidden"); + console.log( + "[PassKeeper] Import complete:", + data.imported, + "imported,", + data.skipped, + "skipped", + ); await loadVault(); } catch (err) { resultEl.innerHTML = `⚠️ ${escHtml(err.message)}`; - resultEl.className = 'import-result import-result-err'; - resultEl.classList.remove('hidden'); + resultEl.className = "import-result import-result-err"; + resultEl.classList.remove("hidden"); confirmBtn.disabled = false; - confirmBtn.textContent = 'Import items'; + confirmBtn.textContent = "Import items"; } }); } @@ -1982,6 +2118,7 @@ const Vault = (() => { loadMfaStatus(), loadSharingKeysStatus(), loadRecoveryStatus(), + loadAuditLog(0, true), ]); } @@ -1990,22 +2127,100 @@ const Vault = (() => { const res = await apiFetch("/api/auth/mfa/status"); if (!res) return; const data = await res.json(); - renderMfaStatus(data.totp_enabled); + renderMfaStatus(data.totp_enabled, data.backup_codes_remaining); } catch (err) { console.error("loadMfaStatus:", err); } } - function renderMfaStatus(enabled) { + const _auditPageSize = 20; + let _auditOffset = 0; + let _auditTotal = 0; + + async function loadAuditLog(offset = 0, reset = false) { + try { + const res = await apiFetch( + `/api/auth/audit-log?limit=${_auditPageSize}&offset=${offset}`, + ); + if (!res) return; + const data = await res.json(); + _auditTotal = data.total || 0; + _auditOffset = offset + (data.entries || []).length; + + const listEl = document.getElementById("audit-log-list"); + if (!listEl) return; + + if (reset) listEl.innerHTML = ""; + + if (!data.entries || !data.entries.length) { + if (reset) + listEl.innerHTML = + '

No activity recorded yet.

'; + document.getElementById("btn-load-more-audit").style.display = "none"; + return; + } + + // Action label map for friendly display + const ACTION_LABELS = { + "auth.login": "Signed in", + "auth.login_failed": "Failed sign-in attempt", + "auth.login_blocked": "Sign-in blocked (account locked)", + "auth.account_locked": "Account locked", + "auth.logout": "Signed out", + "auth.mfa_verify": "MFA verified", + "auth.mfa_backup_code_used": "MFA backup code used", + "auth.mfa_enable": "MFA enabled", + "auth.mfa_disable": "MFA disabled", + "auth.mfa_backup_codes_regenerated": "Backup codes regenerated", + "auth.change_password": "Password changed", + "auth.recovery_setup": "Recovery code configured", + "auth.recovery_success": "Account recovered", + "auth.recovery_failed": "Failed recovery attempt", + "vault.create": "Vault item created", + "vault.update": "Vault item updated", + "vault.delete": "Vault item deleted", + }; + + data.entries.forEach((e) => { + const label = ACTION_LABELS[e.action] || e.action; + const date = new Date(e.created_at).toLocaleString(); + const isAlert = + e.action.includes("failed") || + e.action.includes("blocked") || + e.action.includes("locked"); + const row = document.createElement("div"); + row.style.cssText = `display:flex;justify-content:space-between;align-items:flex-start;padding:7px 0;border-bottom:1px solid #f3f4f6;gap:8px;`; + row.innerHTML = ` + ${label} + ${date}
${e.ip_address || ""}
`; + listEl.appendChild(row); + }); + + const loadMoreBtn = document.getElementById("btn-load-more-audit"); + if (_auditOffset < _auditTotal) { + loadMoreBtn.style.display = "inline-block"; + loadMoreBtn.onclick = () => loadAuditLog(_auditOffset, false); + } else { + loadMoreBtn.style.display = "none"; + } + } catch (err) { + console.error("loadAuditLog:", err); + } + } + + function renderMfaStatus(enabled, backupCodesRemaining) { const statusText = document.getElementById("mfa-status-text"); const actions = document.getElementById("mfa-actions"); document.getElementById("mfa-setup-area").classList.add("hidden"); document.getElementById("mfa-disable-area").classList.add("hidden"); if (enabled) { - statusText.textContent = "✅ Two-factor authentication is enabled."; + const remaining = + backupCodesRemaining != null ? backupCodesRemaining : "?"; + statusText.innerHTML = `✅ Two-factor authentication is enabled. (${remaining} backup code${remaining !== 1 ? "s" : ""} remaining)`; actions.innerHTML = - ''; + '' + + ' '; document .getElementById("btn-mfa-disable") ?.addEventListener("click", () => { @@ -2015,6 +2230,9 @@ const Vault = (() => { document.getElementById("mfa-actions").innerHTML = ""; document.getElementById("mfa-disable-code").focus(); }); + document + .getElementById("btn-regen-backup-codes") + ?.addEventListener("click", handleRegenerateBackupCodes); } else { statusText.textContent = "Two-factor authentication is not enabled."; actions.innerHTML = @@ -2054,14 +2272,108 @@ const Vault = (() => { body: JSON.stringify({ secret: _pendingMfaSecret, totp_code: code }), }); if (!res) return; + const data = await res.json(); + if (!res.ok) { + showToast(data.error || "Failed to enable MFA", "error"); + return; + } _pendingMfaSecret = null; - showToast("MFA enabled successfully"); - renderMfaStatus(true); + document.getElementById("mfa-setup-area").classList.add("hidden"); + + // Display one-time backup codes immediately after enabling + if (data.backup_codes && data.backup_codes.length) { + showBackupCodesModal(data.backup_codes, true); + } else { + showToast("MFA enabled successfully"); + renderMfaStatus(true); + } } catch (err) { showToast("Failed to enable MFA: " + err.message, "error"); } } + /** + * Show backup codes in a modal. When `isFirstTime` is true the modal + * emphasises that these will not be shown again. + */ + function showBackupCodesModal(codes, isFirstTime = false) { + const existing = document.getElementById("__pk_backup_modal__"); + if (existing) existing.remove(); + + const codesHtml = codes + .map( + (c) => + `${c}`, + ) + .join(" "); + + const overlay = document.createElement("div"); + overlay.id = "__pk_backup_modal__"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;"; + overlay.innerHTML = ` +
+

+ ${isFirstTime ? "🔐 MFA enabled — save your backup codes" : "🔐 New backup codes"} +

+ ${ + isFirstTime + ? `

+ These codes let you sign in if you lose access to your authenticator app. + Save them now — they will not be shown again. +

` + : `

+ Your previous codes have been invalidated. Save these new codes securely. +

` + } +
+ ${codesHtml} +
+
+ + +
+
`; + document.body.appendChild(overlay); + + document + .getElementById("__pk_copy_codes__") + .addEventListener("click", () => { + navigator.clipboard + .writeText(codes.join("\n")) + .then(() => showToast("Backup codes copied")); + }); + document + .getElementById("__pk_close_backup__") + .addEventListener("click", () => { + overlay.remove(); + renderMfaStatus(true); + }); + } + + async function handleRegenerateBackupCodes() { + const code = prompt( + "Enter your current authenticator code to regenerate backup codes:", + ); + if (!code) return; + try { + const res = await apiFetch("/api/auth/mfa/backup-codes/regenerate", { + method: "POST", + body: JSON.stringify({ totp_code: code.trim() }), + }); + if (!res) return; + const data = await res.json(); + if (!res.ok) { + showToast(data.error || "Failed to regenerate", "error"); + return; + } + showBackupCodesModal(data.backup_codes, false); + console.log("[PassKeeper] MFA backup codes regenerated"); + } catch (err) { + showToast("Failed to regenerate backup codes: " + err.message, "error"); + } + } + async function handleMfaDisableConfirm() { const code = document.getElementById("mfa-disable-code").value.trim(); if (!code) return; @@ -2214,9 +2526,16 @@ const Vault = (() => { // Re-encrypt the name if it was previously encrypted. let encNamePayload = {}; if (item.enc_name && item.iv_name) { - const plainName = await Crypto.decryptName(vaultKey, item.enc_name, item.iv_name); + const plainName = await Crypto.decryptName( + vaultKey, + item.enc_name, + item.iv_name, + ); if (plainName) { - const { enc_name, iv_name } = await Crypto.encryptName(newVaultKey, plainName); + const { enc_name, iv_name } = await Crypto.encryptName( + newVaultKey, + plainName, + ); encNamePayload = { enc_name, iv_name }; } } @@ -2486,7 +2805,11 @@ const Vault = (() => { else if (_activeFilter.type === "folder") renderItemList(_items.filter((i) => i.folder_id === _activeFilter.value)); else if (_activeFilter.type === "tag") - renderItemList(_items.filter((i) => (i.plain?.tags || []).includes(_activeFilter.value))); + renderItemList( + _items.filter((i) => + (i.plain?.tags || []).includes(_activeFilter.value), + ), + ); } function filterByFolder(folderId, name) { @@ -2588,7 +2911,7 @@ const Vault = (() => { setVal("field-password", p.password); setVal("field-totp-uri", p.totp_uri || ""); setVal("field-notes", p.notes); - setVal("field-tags", (p.tags || []).join(', ')); + setVal("field-tags", (p.tags || []).join(", ")); break; case "note": setVal("field-note-body", p.note_body); @@ -2739,7 +3062,7 @@ const Vault = (() => { const { enc_name, iv_name } = await Crypto.encryptName(vaultKey, name); const folderVal = getVal("field-folder"); const payload = { - name: itemType, // non-sensitive server-side label — real name is in enc_name + name: itemType, // non-sensitive server-side label — real name is in enc_name item_type: itemType, folder_id: folderVal ? parseInt(folderVal) : null, enc_data, @@ -2899,7 +3222,7 @@ const Vault = (() => { // Auto-clear clipboard after 30 seconds — industry-standard hygiene. if (_clipboardClearTimer) clearTimeout(_clipboardClearTimer); _clipboardClearTimer = setTimeout(() => { - navigator.clipboard.writeText('').catch(() => {}); + navigator.clipboard.writeText("").catch(() => {}); _clipboardClearTimer = null; }, 30_000); }) @@ -3078,7 +3401,9 @@ const Vault = (() => { const preview = document.getElementById("field-tags-preview"); if (!preview) return; const tags = _parseTags(e.target.value); - preview.innerHTML = tags.map(t => `${escHtml(t)}`).join(''); + preview.innerHTML = tags + .map((t) => `${escHtml(t)}`) + .join(""); }); // Sidebar navigation @@ -3171,14 +3496,16 @@ const Vault = (() => { ?.addEventListener("click", () => closeModal("settings-modal")); // Web-app session timeout setting. - document.getElementById("web-idle-select")?.addEventListener("change", (e) => { - const mins = parseInt(e.target.value, 10); - localStorage.setItem(WEB_IDLE_KEY, mins); - _resetWebIdleTimer(); - const label = mins === 0 ? 'never' : mins + ' min'; - showToast('Auto-lock set to ' + label + '.'); - console.log('[PassKeeper] Web idle timeout set to', label); - }); + document + .getElementById("web-idle-select") + ?.addEventListener("change", (e) => { + const mins = parseInt(e.target.value, 10); + localStorage.setItem(WEB_IDLE_KEY, mins); + _resetWebIdleTimer(); + const label = mins === 0 ? "never" : mins + " min"; + showToast("Auto-lock set to " + label + "."); + console.log("[PassKeeper] Web idle timeout set to", label); + }); document .getElementById("settings-modal") ?.addEventListener("click", (e) => { @@ -3363,13 +3690,19 @@ const Vault = (() => { // ── Browser history / back-button support ───────────────────────────────── const _hashToView = (hash) => { - const v = (hash || '').replace('#', '').trim(); - const valid = ['vault', 'security', 'sharing', 'emergency', 'import-export']; - return valid.includes(v) ? v : 'vault'; + const v = (hash || "").replace("#", "").trim(); + const valid = [ + "vault", + "security", + "sharing", + "emergency", + "import-export", + ]; + return valid.includes(v) ? v : "vault"; }; const _initialView = _hashToView(window.location.hash); - history.replaceState({ view: _initialView }, '', window.location.href); - window.addEventListener('popstate', (e) => { + history.replaceState({ view: _initialView }, "", window.location.href); + window.addEventListener("popstate", (e) => { const view = e.state?.view || _hashToView(window.location.hash); switchView(view, { pushState: false }); }); @@ -3382,7 +3715,8 @@ const Vault = (() => { } else { loadVault(); // Restore view from hash after vault loads (avoids rendering before decrypt). - if (_initialView !== 'vault') switchView(_initialView, { pushState: false }); + if (_initialView !== "vault") + switchView(_initialView, { pushState: false }); } } @@ -3422,7 +3756,7 @@ const Vault = (() => { init, // Exposed for inline onclick on the sidebar li — ensures the click reaches // switchView regardless of addEventListener binding order or timing. - switchToImportExport: () => switchView('import-export'), + switchToImportExport: () => switchView("import-export"), }; })(); diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index e753f88..5f03d86 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -1,8 +1,5 @@ -{% extends "base.html" %} -{% block title %}Sign In — PassKeeper{% endblock %} -{% block body_class %}auth-page{% endblock %} - -{% block body %} +{% extends "base.html" %} {% block title %}Sign In — PassKeeper{% endblock %} {% +block body_class %}auth-page{% endblock %} {% block body %}
@@ -45,29 +64,65 @@
-{% endblock %} - -{% block scripts %} +{% endblock %} {% block scripts %} {% endblock %} diff --git a/app/templates/vault/index.html b/app/templates/vault/index.html index 6936258..768445a 100644 --- a/app/templates/vault/index.html +++ b/app/templates/vault/index.html @@ -899,6 +899,16 @@
+ + + +
+

📋 Login & Activity History

+

Recent account activity. Unfamiliar entries may indicate unauthorised access.

+
+ +
+

⚠️ Danger Zone

@@ -1051,4 +1061,4 @@ -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/extension/popup/popup.html b/extension/popup/popup.html index 3f0369c..e19f460 100644 --- a/extension/popup/popup.html +++ b/extension/popup/popup.html @@ -1,340 +1,627 @@ - + + + + PassKeeper + + - - - PassKeeper - - - - -

- - -