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 = ``;
- 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 = (() => {
- ${breached.map(i => {
- const count = hibpResults.find(r => r.item.id === i.id)?.count || 0;
- return `-
+ ${breached
+ .map((i) => {
+ const count =
+ hibpResults.find((r) => r.item.id === i.id)?.count || 0;
+ return `
-
${escHtml(i.name)}
- ${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? 's' : ''} in breaches
+ ${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? "s" : ""} in breaches
`;
- }).join("")}
+ })
+ .join("")}
`;
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 = `
`;
- 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.slice(0, 5).map((r) =>
- `- ${escHtml(r.name)} — ${escHtml(r.username || '(no username)')}
`
- ).join('')}
- ${rows.length > 5 ? `…and ${rows.length - 5} more.
` : ''}`;
+ ${rows
+ .slice(0, 5)
+ .map(
+ (r) =>
+ `- ${escHtml(r.name)} — ${escHtml(r.username || "(no username)")}
`,
+ )
+ .join("")}
+ ${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 @@
Two-factor authentication
-
Enter the 6-digit code from your authenticator app.
+
+ Enter the 6-digit code from your authenticator app.
+
-{% 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
-
-
-
-
-