From 5a240b081ddaabdca4febd0a9fe3125dbfa4d51c Mon Sep 17 00:00:00 2001 From: Timothy Farrell Date: Sun, 17 May 2026 17:46:07 +0000 Subject: [PATCH] Add lock settings. --- dist/index.html | 948 +++++++++++++++++---------- src/components/LockScreen.svelte | 3 + src/components/MainLayout.svelte | 17 +- src/components/SettingsDialog.svelte | 149 +++++ src/lib/storage/db.js | 25 + src/lib/stores/security.svelte.js | 50 +- src/lib/stores/settings.svelte.js | 35 + 7 files changed, 854 insertions(+), 373 deletions(-) create mode 100644 src/components/SettingsDialog.svelte create mode 100644 src/lib/stores/settings.svelte.js diff --git a/dist/index.html b/dist/index.html index bbbe821..cc55f1c 100644 --- a/dist/index.html +++ b/dist/index.html @@ -4663,6 +4663,22 @@ function bind_group(inputs, group_index, input, get, set = get) { }); } /** +* @param {HTMLInputElement} input +* @param {() => unknown} get +* @param {(value: unknown) => void} set +* @returns {void} +*/ +function bind_checked(input, get, set = get) { + listen_to_event_and_reset_event(input, "change", (is_reset) => { + set(is_reset ? input.defaultChecked : input.checked); + }); + if (hydrating && input.defaultChecked !== input.checked || untrack(get) == null) set(input.checked); + render_effect(() => { + var value = get(); + input.checked = Boolean(value); + }); +} +/** * @template V * @param {Array} group * @param {V} __value @@ -4749,91 +4765,219 @@ if (typeof window !== "undefined") ((window.__svelte ??= {}).v ??= /* @__PURE__ //#region node_modules/svelte/src/internal/flags/legacy.js enable_legacy_mode_flag(); //#endregion -//#region src/lib/stores/security.svelte.js -var DEFAULT_AUTO_LOCK_MINUTES = 5; -var autoLockTimer = null; -var autoLockMinutes = 5; -function startAutoLock(minutes = DEFAULT_AUTO_LOCK_MINUTES) { - autoLockMinutes = minutes; - resetAutoLock(); - const events = [ - "mousedown", - "keydown", - "scroll", - "touchstart" - ]; - const handler = () => resetAutoLock(); - events.forEach((evt) => window.addEventListener(evt, handler, { passive: true })); - document.addEventListener("visibilitychange", handleVisibilityChange); - window.addEventListener("beforeunload", clearKeyOnExit); - window.__vaultCleanup = () => { - events.forEach((evt) => window.removeEventListener(evt, handler)); - document.removeEventListener("visibilitychange", handleVisibilityChange); - window.removeEventListener("beforeunload", clearKeyOnExit); - if (autoLockTimer) clearTimeout(autoLockTimer); - }; +//#region node_modules/idb/build/index.js +var instanceOfAny = (object, constructors) => constructors.some((c) => object instanceof c); +var idbProxyableTypes; +var cursorAdvanceMethods; +function getIdbProxyableTypes() { + return idbProxyableTypes || (idbProxyableTypes = [ + IDBDatabase, + IDBObjectStore, + IDBIndex, + IDBCursor, + IDBTransaction + ]); } -/** -* Reset the auto-lock timer. -*/ -function resetAutoLock() { - if (autoLockTimer) clearTimeout(autoLockTimer); - autoLockTimer = setTimeout(() => { - app$1.lockVault(); - }, autoLockMinutes * 60 * 1e3); +function getCursorAdvanceMethods() { + return cursorAdvanceMethods || (cursorAdvanceMethods = [ + IDBCursor.prototype.advance, + IDBCursor.prototype.continue, + IDBCursor.prototype.continuePrimaryKey + ]); } -/** -* Handle visibility change — lock when user switches away from tab. -*/ -function handleVisibilityChange() { - if (document.hidden && app$1.isUnlocked) app$1.lockVault(); +var transactionDoneMap = /* @__PURE__ */ new WeakMap(); +var transformCache = /* @__PURE__ */ new WeakMap(); +var reverseTransformCache = /* @__PURE__ */ new WeakMap(); +function promisifyRequest(request) { + const promise = new Promise((resolve, reject) => { + const unlisten = () => { + request.removeEventListener("success", success); + request.removeEventListener("error", error); + }; + const success = () => { + resolve(wrap(request.result)); + unlisten(); + }; + const error = () => { + reject(request.error); + unlisten(); + }; + request.addEventListener("success", success); + request.addEventListener("error", error); + }); + reverseTransformCache.set(promise, request); + return promise; } -/** -* Clear the encryption key when the page is closing. -*/ -function clearKeyOnExit() { - app$1.encryptionKey = null; +function cacheDonePromiseForTransaction(tx) { + if (transactionDoneMap.has(tx)) return; + const done = new Promise((resolve, reject) => { + const unlisten = () => { + tx.removeEventListener("complete", complete); + tx.removeEventListener("error", error); + tx.removeEventListener("abort", error); + }; + const complete = () => { + resolve(); + unlisten(); + }; + const error = () => { + reject(tx.error || new DOMException("AbortError", "AbortError")); + unlisten(); + }; + tx.addEventListener("complete", complete); + tx.addEventListener("error", error); + tx.addEventListener("abort", error); + }); + transactionDoneMap.set(tx, done); } -/** -* Stop auto-lock (e.g., when manually locking). -*/ -function stopAutoLock() { - if (autoLockTimer) { - clearTimeout(autoLockTimer); - autoLockTimer = null; - } -} -//#endregion -//#region src/lib/stores/app.svelte.js -var AppStore = class { - #isUnlocked = /* @__PURE__ */ state(false); - get isUnlocked() { - return get(this.#isUnlocked); - } - set isUnlocked(value) { - set(this.#isUnlocked, value, true); - } - #encryptionKey = /* @__PURE__ */ state(null); - get encryptionKey() { - return get(this.#encryptionKey); - } - set encryptionKey(value) { - set(this.#encryptionKey, value, true); - } - #salt = /* @__PURE__ */ state(null); - get salt() { - return get(this.#salt); - } - set salt(value) { - set(this.#salt, value, true); - } - lockVault() { - stopAutoLock(); - this.encryptionKey = null; - this.isUnlocked = false; +var idbProxyTraps = { + get(target, prop, receiver) { + if (target instanceof IDBTransaction) { + if (prop === "done") return transactionDoneMap.get(target); + if (prop === "store") return receiver.objectStoreNames[1] ? void 0 : receiver.objectStore(receiver.objectStoreNames[0]); + } + return wrap(target[prop]); + }, + set(target, prop, value) { + target[prop] = value; + return true; + }, + has(target, prop) { + if (target instanceof IDBTransaction && (prop === "done" || prop === "store")) return true; + return prop in target; } }; -var app$1 = new AppStore(); +function replaceTraps(callback) { + idbProxyTraps = callback(idbProxyTraps); +} +function wrapFunction(func) { + if (getCursorAdvanceMethods().includes(func)) return function(...args) { + func.apply(unwrap(this), args); + return wrap(this.request); + }; + return function(...args) { + return wrap(func.apply(unwrap(this), args)); + }; +} +function transformCachableValue(value) { + if (typeof value === "function") return wrapFunction(value); + if (value instanceof IDBTransaction) cacheDonePromiseForTransaction(value); + if (instanceOfAny(value, getIdbProxyableTypes())) return new Proxy(value, idbProxyTraps); + return value; +} +function wrap(value) { + if (value instanceof IDBRequest) return promisifyRequest(value); + if (transformCache.has(value)) return transformCache.get(value); + const newValue = transformCachableValue(value); + if (newValue !== value) { + transformCache.set(value, newValue); + reverseTransformCache.set(newValue, value); + } + return newValue; +} +var unwrap = (value) => reverseTransformCache.get(value); +/** +* Open a database. +* +* @param name Name of the database. +* @param version Schema version. +* @param callbacks Additional callbacks. +*/ +function openDB(name, version, { blocked, upgrade, blocking, terminated } = {}) { + const request = indexedDB.open(name, version); + const openPromise = wrap(request); + if (upgrade) request.addEventListener("upgradeneeded", (event) => { + upgrade(wrap(request.result), event.oldVersion, event.newVersion, wrap(request.transaction), event); + }); + if (blocked) request.addEventListener("blocked", (event) => blocked(event.oldVersion, event.newVersion, event)); + openPromise.then((db) => { + if (terminated) db.addEventListener("close", () => terminated()); + if (blocking) db.addEventListener("versionchange", (event) => blocking(event.oldVersion, event.newVersion, event)); + }).catch(() => {}); + return openPromise; +} +var readMethods = [ + "get", + "getKey", + "getAll", + "getAllKeys", + "count" +]; +var writeMethods = [ + "put", + "add", + "delete", + "clear" +]; +var cachedMethods = /* @__PURE__ */ new Map(); +function getMethod(target, prop) { + if (!(target instanceof IDBDatabase && !(prop in target) && typeof prop === "string")) return; + if (cachedMethods.get(prop)) return cachedMethods.get(prop); + const targetFuncName = prop.replace(/FromIndex$/, ""); + const useIndex = prop !== targetFuncName; + const isWrite = writeMethods.includes(targetFuncName); + if (!(targetFuncName in (useIndex ? IDBIndex : IDBObjectStore).prototype) || !(isWrite || readMethods.includes(targetFuncName))) return; + const method = async function(storeName, ...args) { + const tx = this.transaction(storeName, isWrite ? "readwrite" : "readonly"); + let target = tx.store; + if (useIndex) target = target.index(args.shift()); + return (await Promise.all([target[targetFuncName](...args), isWrite && tx.done]))[0]; + }; + cachedMethods.set(prop, method); + return method; +} +replaceTraps((oldTraps) => ({ + ...oldTraps, + get: (target, prop, receiver) => getMethod(target, prop) || oldTraps.get(target, prop, receiver), + has: (target, prop) => !!getMethod(target, prop) || oldTraps.has(target, prop) +})); +var advanceMethodProps = [ + "continue", + "continuePrimaryKey", + "advance" +]; +var methodMap = {}; +var advanceResults = /* @__PURE__ */ new WeakMap(); +var ittrProxiedCursorToOriginalProxy = /* @__PURE__ */ new WeakMap(); +var cursorIteratorTraps = { get(target, prop) { + if (!advanceMethodProps.includes(prop)) return target[prop]; + let cachedFunc = methodMap[prop]; + if (!cachedFunc) cachedFunc = methodMap[prop] = function(...args) { + advanceResults.set(this, ittrProxiedCursorToOriginalProxy.get(this)[prop](...args)); + }; + return cachedFunc; +} }; +async function* iterate(...args) { + let cursor = this; + if (!(cursor instanceof IDBCursor)) cursor = await cursor.openCursor(...args); + if (!cursor) return; + cursor = cursor; + const proxiedCursor = new Proxy(cursor, cursorIteratorTraps); + ittrProxiedCursorToOriginalProxy.set(proxiedCursor, cursor); + reverseTransformCache.set(proxiedCursor, unwrap(cursor)); + while (cursor) { + yield proxiedCursor; + cursor = await (advanceResults.get(proxiedCursor) || cursor.continue()); + advanceResults.delete(proxiedCursor); + } +} +function isIteratorProp(target, prop) { + return prop === Symbol.asyncIterator && instanceOfAny(target, [ + IDBIndex, + IDBObjectStore, + IDBCursor + ]) || prop === "iterate" && instanceOfAny(target, [IDBIndex, IDBObjectStore]); +} +replaceTraps((oldTraps) => ({ + ...oldTraps, + get(target, prop, receiver) { + if (isIteratorProp(target, prop)) return iterate; + return oldTraps.get(target, prop, receiver); + }, + has(target, prop) { + return isIteratorProp(target, prop) || oldTraps.has(target, prop); + } +})); //#endregion //#region src/lib/models/schema.js /** @@ -5164,220 +5308,6 @@ function generatePassword({ length = 16, uppercase = true, lowercase = true, dig return password; } //#endregion -//#region node_modules/idb/build/index.js -var instanceOfAny = (object, constructors) => constructors.some((c) => object instanceof c); -var idbProxyableTypes; -var cursorAdvanceMethods; -function getIdbProxyableTypes() { - return idbProxyableTypes || (idbProxyableTypes = [ - IDBDatabase, - IDBObjectStore, - IDBIndex, - IDBCursor, - IDBTransaction - ]); -} -function getCursorAdvanceMethods() { - return cursorAdvanceMethods || (cursorAdvanceMethods = [ - IDBCursor.prototype.advance, - IDBCursor.prototype.continue, - IDBCursor.prototype.continuePrimaryKey - ]); -} -var transactionDoneMap = /* @__PURE__ */ new WeakMap(); -var transformCache = /* @__PURE__ */ new WeakMap(); -var reverseTransformCache = /* @__PURE__ */ new WeakMap(); -function promisifyRequest(request) { - const promise = new Promise((resolve, reject) => { - const unlisten = () => { - request.removeEventListener("success", success); - request.removeEventListener("error", error); - }; - const success = () => { - resolve(wrap(request.result)); - unlisten(); - }; - const error = () => { - reject(request.error); - unlisten(); - }; - request.addEventListener("success", success); - request.addEventListener("error", error); - }); - reverseTransformCache.set(promise, request); - return promise; -} -function cacheDonePromiseForTransaction(tx) { - if (transactionDoneMap.has(tx)) return; - const done = new Promise((resolve, reject) => { - const unlisten = () => { - tx.removeEventListener("complete", complete); - tx.removeEventListener("error", error); - tx.removeEventListener("abort", error); - }; - const complete = () => { - resolve(); - unlisten(); - }; - const error = () => { - reject(tx.error || new DOMException("AbortError", "AbortError")); - unlisten(); - }; - tx.addEventListener("complete", complete); - tx.addEventListener("error", error); - tx.addEventListener("abort", error); - }); - transactionDoneMap.set(tx, done); -} -var idbProxyTraps = { - get(target, prop, receiver) { - if (target instanceof IDBTransaction) { - if (prop === "done") return transactionDoneMap.get(target); - if (prop === "store") return receiver.objectStoreNames[1] ? void 0 : receiver.objectStore(receiver.objectStoreNames[0]); - } - return wrap(target[prop]); - }, - set(target, prop, value) { - target[prop] = value; - return true; - }, - has(target, prop) { - if (target instanceof IDBTransaction && (prop === "done" || prop === "store")) return true; - return prop in target; - } -}; -function replaceTraps(callback) { - idbProxyTraps = callback(idbProxyTraps); -} -function wrapFunction(func) { - if (getCursorAdvanceMethods().includes(func)) return function(...args) { - func.apply(unwrap(this), args); - return wrap(this.request); - }; - return function(...args) { - return wrap(func.apply(unwrap(this), args)); - }; -} -function transformCachableValue(value) { - if (typeof value === "function") return wrapFunction(value); - if (value instanceof IDBTransaction) cacheDonePromiseForTransaction(value); - if (instanceOfAny(value, getIdbProxyableTypes())) return new Proxy(value, idbProxyTraps); - return value; -} -function wrap(value) { - if (value instanceof IDBRequest) return promisifyRequest(value); - if (transformCache.has(value)) return transformCache.get(value); - const newValue = transformCachableValue(value); - if (newValue !== value) { - transformCache.set(value, newValue); - reverseTransformCache.set(newValue, value); - } - return newValue; -} -var unwrap = (value) => reverseTransformCache.get(value); -/** -* Open a database. -* -* @param name Name of the database. -* @param version Schema version. -* @param callbacks Additional callbacks. -*/ -function openDB(name, version, { blocked, upgrade, blocking, terminated } = {}) { - const request = indexedDB.open(name, version); - const openPromise = wrap(request); - if (upgrade) request.addEventListener("upgradeneeded", (event) => { - upgrade(wrap(request.result), event.oldVersion, event.newVersion, wrap(request.transaction), event); - }); - if (blocked) request.addEventListener("blocked", (event) => blocked(event.oldVersion, event.newVersion, event)); - openPromise.then((db) => { - if (terminated) db.addEventListener("close", () => terminated()); - if (blocking) db.addEventListener("versionchange", (event) => blocking(event.oldVersion, event.newVersion, event)); - }).catch(() => {}); - return openPromise; -} -var readMethods = [ - "get", - "getKey", - "getAll", - "getAllKeys", - "count" -]; -var writeMethods = [ - "put", - "add", - "delete", - "clear" -]; -var cachedMethods = /* @__PURE__ */ new Map(); -function getMethod(target, prop) { - if (!(target instanceof IDBDatabase && !(prop in target) && typeof prop === "string")) return; - if (cachedMethods.get(prop)) return cachedMethods.get(prop); - const targetFuncName = prop.replace(/FromIndex$/, ""); - const useIndex = prop !== targetFuncName; - const isWrite = writeMethods.includes(targetFuncName); - if (!(targetFuncName in (useIndex ? IDBIndex : IDBObjectStore).prototype) || !(isWrite || readMethods.includes(targetFuncName))) return; - const method = async function(storeName, ...args) { - const tx = this.transaction(storeName, isWrite ? "readwrite" : "readonly"); - let target = tx.store; - if (useIndex) target = target.index(args.shift()); - return (await Promise.all([target[targetFuncName](...args), isWrite && tx.done]))[0]; - }; - cachedMethods.set(prop, method); - return method; -} -replaceTraps((oldTraps) => ({ - ...oldTraps, - get: (target, prop, receiver) => getMethod(target, prop) || oldTraps.get(target, prop, receiver), - has: (target, prop) => !!getMethod(target, prop) || oldTraps.has(target, prop) -})); -var advanceMethodProps = [ - "continue", - "continuePrimaryKey", - "advance" -]; -var methodMap = {}; -var advanceResults = /* @__PURE__ */ new WeakMap(); -var ittrProxiedCursorToOriginalProxy = /* @__PURE__ */ new WeakMap(); -var cursorIteratorTraps = { get(target, prop) { - if (!advanceMethodProps.includes(prop)) return target[prop]; - let cachedFunc = methodMap[prop]; - if (!cachedFunc) cachedFunc = methodMap[prop] = function(...args) { - advanceResults.set(this, ittrProxiedCursorToOriginalProxy.get(this)[prop](...args)); - }; - return cachedFunc; -} }; -async function* iterate(...args) { - let cursor = this; - if (!(cursor instanceof IDBCursor)) cursor = await cursor.openCursor(...args); - if (!cursor) return; - cursor = cursor; - const proxiedCursor = new Proxy(cursor, cursorIteratorTraps); - ittrProxiedCursorToOriginalProxy.set(proxiedCursor, cursor); - reverseTransformCache.set(proxiedCursor, unwrap(cursor)); - while (cursor) { - yield proxiedCursor; - cursor = await (advanceResults.get(proxiedCursor) || cursor.continue()); - advanceResults.delete(proxiedCursor); - } -} -function isIteratorProp(target, prop) { - return prop === Symbol.asyncIterator && instanceOfAny(target, [ - IDBIndex, - IDBObjectStore, - IDBCursor - ]) || prop === "iterate" && instanceOfAny(target, [IDBIndex, IDBObjectStore]); -} -replaceTraps((oldTraps) => ({ - ...oldTraps, - get(target, prop, receiver) { - if (isIteratorProp(target, prop)) return iterate; - return oldTraps.get(target, prop, receiver); - }, - has(target, prop) { - return isIteratorProp(target, prop) || oldTraps.has(target, prop); - } -})); -//#endregion //#region src/lib/storage/db.js /** * IndexedDB storage layer using the `idb` wrapper. @@ -5469,6 +5399,25 @@ async function isVaultInitialized() { return (await loadVaultMeta()).salt !== null; } /** +* Save a single setting as a key/value pair in the meta store. +* @param {string} key +* @param {*} value +*/ +async function saveSetting(key, value) { + await (await getDb()).put("meta", { + key: "setting:" + key, + value + }); +} +/** +* Load a single setting from the meta store. +* @param {string} key +* @returns {Promise<*>} The value, or undefined if not set +*/ +async function getSetting(key) { + return (await (await getDb()).get("meta", "setting:" + key))?.value ?? void 0; +} +/** * @typedef {import('../models/schema.js').Group} Group */ /** @@ -5701,6 +5650,129 @@ async function importAll(data, mode = "merge", sourcePassword = "", targetKey = }; } //#endregion +//#region src/lib/stores/settings.svelte.js +var SettingsStore = class { + #autoLockMinutes = /* @__PURE__ */ state(5); + get autoLockMinutes() { + return get(this.#autoLockMinutes); + } + set autoLockMinutes(value) { + set(this.#autoLockMinutes, value, true); + } + #lockOnTabSwitch = /* @__PURE__ */ state(true); + get lockOnTabSwitch() { + return get(this.#lockOnTabSwitch); + } + set lockOnTabSwitch(value) { + set(this.#lockOnTabSwitch, value, true); + } + async load() { + const minutes = await getSetting("autoLockMinutes"); + const tabSwitch = await getSetting("lockOnTabSwitch"); + this.autoLockMinutes = minutes != null ? Number(minutes) : 5; + this.lockOnTabSwitch = tabSwitch != null ? Boolean(tabSwitch) : true; + } + /** + * Persist current settings to IndexedDB. + */ + async save() { + await saveSetting("autoLockMinutes", this.autoLockMinutes); + await saveSetting("lockOnTabSwitch", this.lockOnTabSwitch); + } +}; +var settings = new SettingsStore(); +//#endregion +//#region src/lib/stores/security.svelte.js +var autoLockTimer = null; +var activityHandler = null; +var activityEvents = null; +function startAutoLock() { + resetAutoLock(); + if (!activityHandler) { + activityEvents = [ + "mousedown", + "keydown", + "scroll", + "touchstart" + ]; + activityHandler = () => resetAutoLock(); + activityEvents.forEach((evt) => window.addEventListener(evt, activityHandler, { passive: true })); + } + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("beforeunload", clearKeyOnExit); + window.__vaultCleanup = stopAutoLock; +} +/** +* Reset the auto-lock timer using the current settings value. +*/ +function resetAutoLock() { + if (autoLockTimer) clearTimeout(autoLockTimer); + const minutes = settings.autoLockMinutes ?? 5; + autoLockTimer = setTimeout(() => { + app$1.lockVault(); + }, minutes * 60 * 1e3); +} +/** +* Handle visibility change — lock when user switches away from tab +* (only if lockOnTabSwitch is enabled). +*/ +function handleVisibilityChange() { + if (document.hidden && app$1.isUnlocked && settings.lockOnTabSwitch) app$1.lockVault(); +} +/** +* Clear the encryption key when the page is closing. +*/ +function clearKeyOnExit() { + app$1.encryptionKey = null; +} +/** +* Stop auto-lock and remove all listeners. +*/ +function stopAutoLock() { + if (autoLockTimer) { + clearTimeout(autoLockTimer); + autoLockTimer = null; + } + if (activityHandler && activityEvents) { + activityEvents.forEach((evt) => window.removeEventListener(evt, activityHandler)); + activityHandler = null; + activityEvents = null; + } + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("beforeunload", clearKeyOnExit); +} +//#endregion +//#region src/lib/stores/app.svelte.js +var AppStore = class { + #isUnlocked = /* @__PURE__ */ state(false); + get isUnlocked() { + return get(this.#isUnlocked); + } + set isUnlocked(value) { + set(this.#isUnlocked, value, true); + } + #encryptionKey = /* @__PURE__ */ state(null); + get encryptionKey() { + return get(this.#encryptionKey); + } + set encryptionKey(value) { + set(this.#encryptionKey, value, true); + } + #salt = /* @__PURE__ */ state(null); + get salt() { + return get(this.#salt); + } + set salt(value) { + set(this.#salt, value, true); + } + lockVault() { + stopAutoLock(); + this.encryptionKey = null; + this.isUnlocked = false; + } +}; +var app$1 = new AppStore(); +//#endregion //#region src/lib/autofocus.js /** * Action that autofocuses an element after mount when the condition is truthy. @@ -5711,9 +5783,9 @@ function autofocus(node, condition = true) { } //#endregion //#region src/components/LockScreen.svelte -var root_1$7 = /* @__PURE__ */ from_html(``); +var root_1$8 = /* @__PURE__ */ from_html(``); var root_2$6 = /* @__PURE__ */ from_html(`
`); -var root$7 = /* @__PURE__ */ from_html(`
🔐

Password Vault

`); +var root$8 = /* @__PURE__ */ from_html(`
🔐

Password Vault

`); function LockScreen($$anchor, $$props) { push($$props, true); let masterPassword = /* @__PURE__ */ state(""); @@ -5745,6 +5817,7 @@ function LockScreen($$anchor, $$props) { app$1.encryptionKey = await deriveKey(get(masterPassword), salt); await saveVaultMeta(salt, testEncrypted, testPlaintext); await ensureTrashGroup(); + await settings.load(); app$1.isUnlocked = true; startAutoLock(); } else { @@ -5762,6 +5835,7 @@ function LockScreen($$anchor, $$props) { } app$1.salt = meta.salt; app$1.encryptionKey = key; + await settings.load(); app$1.isUnlocked = true; startAutoLock(); } @@ -5773,14 +5847,14 @@ function LockScreen($$anchor, $$props) { set(masterPassword, ""); set(confirmPassword, ""); } - var div = root$7(); + var div = root$8(); var div_1 = child(div); var p = sibling(child(div_1), 4); var text = child(p, true); reset(p); var node = sibling(p, 2); var consequent = ($$anchor) => { - var div_2 = root_1$7(); + var div_2 = root_1$8(); var text_1 = child(div_2, true); reset(div_2); template_effect(() => set_text(text_1, get(error))); @@ -5894,7 +5968,7 @@ var root_4$5 = /* @__PURE__ */ from_html(``); var root_6$4 = /* @__PURE__ */ from_html(``); -var root$6 = /* @__PURE__ */ from_html(``); +var root$7 = /* @__PURE__ */ from_html(``); function Sidebar($$anchor, $$props) { push($$props, true); let groups = /* @__PURE__ */ state(proxy([])); @@ -5989,7 +6063,7 @@ function Sidebar($$anchor, $$props) { set(groupError, "Failed to delete group: " + e.message); } } - var div = root$6(); + var div = root$7(); var div_1 = sibling(child(div), 2); var input = child(div_1); remove_input_defaults(input); @@ -6162,7 +6236,7 @@ function Sidebar($$anchor, $$props) { delegate(["input", "click"]); //#endregion //#region src/components/EntryList.svelte -var root_1$6 = /* @__PURE__ */ from_html(`
Loading entries...
`); +var root_1$7 = /* @__PURE__ */ from_html(`
Loading entries...
`); var root_2$4 = /* @__PURE__ */ from_html(`
`); var root_4$4 = /* @__PURE__ */ from_html(``); var root_3$4 = /* @__PURE__ */ from_html(`

`); @@ -6170,9 +6244,9 @@ var root_6$3 = /* @__PURE__ */ from_html(`matching " "`, 1); var root_7$4 = /* @__PURE__ */ from_html(``); var root_9$1 = /* @__PURE__ */ from_html(``); var root_10$1 = /* @__PURE__ */ from_html(``); -var root_8$1 = /* @__PURE__ */ from_html(` `); +var root_8$2 = /* @__PURE__ */ from_html(` `); var root_5$3 = /* @__PURE__ */ from_html(`
TitleUsernameURLNotes
`, 1); -var root$5 = /* @__PURE__ */ from_html(`
`); +var root$6 = /* @__PURE__ */ from_html(`
`); function EntryList($$anchor, $$props) { push($$props, true); let entries = /* @__PURE__ */ state(proxy([])); @@ -6211,10 +6285,10 @@ function EntryList($$anchor, $$props) { search.refreshTrigger; loadEntries(); }); - var div = root$5(); + var div = root$6(); var node = child(div); var consequent = ($$anchor) => { - append($$anchor, root_1$6()); + append($$anchor, root_1$7()); }; var consequent_1 = ($$anchor) => { var div_2 = root_2$4(); @@ -6287,7 +6361,7 @@ function EntryList($$anchor, $$props) { reset(thead); var tbody = sibling(thead); each(tbody, 21, () => get(entries), (entry) => entry.id, ($$anchor, entry) => { - var tr_1 = root_8$1(); + var tr_1 = root_8$2(); var td = child(tr_1); var node_4 = child(td); var consequent_6 = ($$anchor) => { @@ -6369,19 +6443,19 @@ function EntryList($$anchor, $$props) { delegate(["click"]); //#endregion //#region src/components/EntryDetail.svelte -var root_1$5 = /* @__PURE__ */ from_html(`
`); +var root_1$6 = /* @__PURE__ */ from_html(`
`); var root_2$3 = /* @__PURE__ */ from_html(`
Loading...
`); var root_3$3 = /* @__PURE__ */ from_html(`
`); var root_4$3 = /* @__PURE__ */ from_html(`
Entry not found
`); var root_6$2 = /* @__PURE__ */ from_html(` `, 1); var root_7$3 = /* @__PURE__ */ from_html(` `, 1); -var root_8 = /* @__PURE__ */ from_html(`
Username
`); +var root_8$1 = /* @__PURE__ */ from_html(`
Username
`); var root_9 = /* @__PURE__ */ from_html(`
URL
`); var root_10 = /* @__PURE__ */ from_html(`
Notes
`); -var root_11$1 = /* @__PURE__ */ from_html(``); +var root_11 = /* @__PURE__ */ from_html(``); var root_12 = /* @__PURE__ */ from_html(``); var root_5$2 = /* @__PURE__ */ from_html(`

Password
`, 1); -var root$4 = /* @__PURE__ */ from_html(`
`); +var root$5 = /* @__PURE__ */ from_html(`
`); function EntryDetail($$anchor, $$props) { push($$props, true); let entry = /* @__PURE__ */ state(null); @@ -6458,10 +6532,10 @@ function EntryDetail($$anchor, $$props) { set(deleting, false); set(showPermanentDeleteConfirm, false); } - var div = root$4(); + var div = root$5(); var node = child(div); var consequent = ($$anchor) => { - var div_1 = root_1$5(); + var div_1 = root_1$6(); var text_1 = child(div_1, true); reset(div_1); template_effect(() => set_text(text_1, get(toast))); @@ -6518,7 +6592,7 @@ function EntryDetail($$anchor, $$props) { var div_8 = sibling(div_6, 2); var node_3 = child(div_8); var consequent_5 = ($$anchor) => { - var div_9 = root_8(); + var div_9 = root_8$1(); var div_10 = sibling(child(div_9), 2); var span = child(div_10); var text_4 = child(span, true); @@ -6589,7 +6663,7 @@ function EntryDetail($$anchor, $$props) { reset(div_5); var node_6 = sibling(div_5, 2); var consequent_8 = ($$anchor) => { - var div_18 = root_11$1(); + var div_18 = root_11(); var div_19 = child(div_18); var p = sibling(child(div_19), 2); var strong = sibling(child(p)); @@ -6676,13 +6750,13 @@ delegate(["click"]); delegate(["click"]); //#endregion //#region src/components/EntryForm.svelte -var root_1$3 = /* @__PURE__ */ from_html(`
Loading...
`); +var root_1$4 = /* @__PURE__ */ from_html(`
Loading...
`); var root_3$2 = /* @__PURE__ */ from_html(`
`); var root_5$1 = /* @__PURE__ */ from_html(`
`); var root_4$2 = /* @__PURE__ */ from_html(`
`); var root_7$2 = /* @__PURE__ */ from_html(``); var root_2$2 = /* @__PURE__ */ from_html(`
`, 1); -var root$2 = /* @__PURE__ */ from_html(`
`); +var root$3 = /* @__PURE__ */ from_html(`
`); function EntryForm($$anchor, $$props) { push($$props, true); let title = /* @__PURE__ */ state(""); @@ -6758,10 +6832,10 @@ function EntryForm($$anchor, $$props) { } set(saving, false); } - var div = root$2(); + var div = root$3(); var node = child(div); var consequent = ($$anchor) => { - append($$anchor, root_1$3()); + append($$anchor, root_1$4()); }; var alternate = ($$anchor) => { var fragment = root_2$2(); @@ -6887,13 +6961,13 @@ function EntryForm($$anchor, $$props) { delegate(["click"]); //#endregion //#region src/components/ImportExport.svelte -var root_1$2 = /* @__PURE__ */ from_html(``); +var root_1$3 = /* @__PURE__ */ from_html(``); var root_3$1 = /* @__PURE__ */ from_html(`
`); var root_4$1 = /* @__PURE__ */ from_html(`
`); var root_6$1 = /* @__PURE__ */ from_html(`

File loaded. Enter the source vault's master password to decrypt and re-encrypt entries under your current vault.

`, 1); var root_7$1 = /* @__PURE__ */ from_html(`

Select how to handle existing data:

`, 1); var root_2$1 = /* @__PURE__ */ from_html(``); -var root$1 = /* @__PURE__ */ from_html(`
`); +var root$2 = /* @__PURE__ */ from_html(`
`); function ImportExport($$anchor, $$props) { push($$props, true); const binding_group = []; @@ -6963,12 +7037,12 @@ function ImportExport($$anchor, $$props) { } set(importing, false); } - var div = root$1(); + var div = root$2(); var button = child(div); var button_1 = sibling(button, 2); var node = sibling(button_1, 2); var consequent = ($$anchor) => { - var div_1 = root_1$2(); + var div_1 = root_1$3(); var div_2 = child(div_1); var div_3 = sibling(child(div_2), 4); var button_2 = child(div_3); @@ -7118,16 +7192,108 @@ function ImportExport($$anchor, $$props) { } delegate(["click", "change"]); //#endregion +//#region src/components/SettingsDialog.svelte +var root_1$2 = /* @__PURE__ */ from_html(``); +var root$1 = /* @__PURE__ */ from_html(`

Settings

`); +function SettingsDialog($$anchor, $$props) { + push($$props, true); + let minutes = /* @__PURE__ */ state(proxy(settings.autoLockMinutes)); + let lockOnTabSwitch = /* @__PURE__ */ state(proxy(settings.lockOnTabSwitch)); + let saving = /* @__PURE__ */ state(false); + const minuteOptions = [ + 1, + 5, + 10, + 15, + 30, + 60 + ]; + async function handleSave() { + set(saving, true); + try { + settings.autoLockMinutes = get(minutes); + settings.lockOnTabSwitch = get(lockOnTabSwitch); + await settings.save(); + startAutoLock(); + } catch (e) { + console.error("Failed to save settings:", e); + } + set(saving, false); + $$props.onBack(); + } + user_effect(() => { + set(minutes, settings.autoLockMinutes, true); + set(lockOnTabSwitch, settings.lockOnTabSwitch, true); + }); + var div = root$1(); + var form = child(div); + var div_1 = sibling(child(form), 2); + var select = sibling(child(div_1), 2); + each(select, 21, () => minuteOptions, index, ($$anchor, m) => { + var option = root_1$2(); + var text = child(option); + reset(option); + var option_value = {}; + template_effect(() => { + set_text(text, `${get(m) ?? ""} ${get(m) === 1 ? "minute" : "minutes"}`); + if (option_value !== (option_value = get(m))) option.value = (option.__value = get(m)) ?? ""; + }); + append($$anchor, option); + }); + reset(select); + var p = sibling(select, 2); + var text_1 = child(p); + reset(p); + reset(div_1); + var div_2 = sibling(div_1, 2); + var label = child(div_2); + var input = child(label); + remove_input_defaults(input); + next(4); + reset(label); + var p_1 = sibling(label, 2); + var text_2 = child(p_1, true); + reset(p_1); + reset(div_2); + var div_3 = sibling(div_2, 2); + var button = child(div_3); + var text_3 = child(button, true); + reset(button); + var button_1 = sibling(button, 2); + reset(div_3); + reset(form); + reset(div); + template_effect(() => { + set_text(text_1, `Vault locks after ${get(minutes) ?? ""} ${get(minutes) === 1 ? "minute" : "minutes"} of inactivity.`); + set_text(text_2, get(lockOnTabSwitch) ? "The vault locks immediately when you switch to another tab." : "The vault stays unlocked even when you switch tabs."); + button.disabled = get(saving); + set_text(text_3, get(saving) ? "Saving..." : "Save"); + }); + event("submit", form, (e) => { + e.preventDefault(); + handleSave(); + }); + bind_select_value(select, () => get(minutes), ($$value) => set(minutes, $$value)); + bind_checked(input, () => get(lockOnTabSwitch), ($$value) => set(lockOnTabSwitch, $$value)); + delegated("click", button_1, function(...$$args) { + $$props.onBack?.apply(this, $$args); + }); + append($$anchor, div); + pop(); +} +delegate(["click"]); +//#endregion //#region src/components/MainLayout.svelte var root_1$1 = /* @__PURE__ */ from_html(``); var root_2 = /* @__PURE__ */ from_html(``); var root_3 = /* @__PURE__ */ from_html(`

`); var root_4 = /* @__PURE__ */ from_html(`

Entry Details

`); var root_5 = /* @__PURE__ */ from_html(`

`); -var root_6 = /* @__PURE__ */ from_html(``); -var root_7 = /* @__PURE__ */ from_html(``); -var root_11 = /* @__PURE__ */ from_html(``); -var root = /* @__PURE__ */ from_html(`
Password Vault
`); +var root_6 = /* @__PURE__ */ from_html(`

Settings

`); +var root_7 = /* @__PURE__ */ from_html(``); +var root_8 = /* @__PURE__ */ from_html(``); +var root_13 = /* @__PURE__ */ from_html(``); +var root = /* @__PURE__ */ from_html(`
Password Vault
`); function MainLayout($$anchor, $$props) { push($$props, true); let sidebarOpen = /* @__PURE__ */ state(false); @@ -7151,8 +7317,12 @@ function MainLayout($$anchor, $$props) { set(viewMode, "form"); set(sidebarOpen, false); } + function goSettings() { + set(viewMode, "settings"); + set(sidebarOpen, false); + } function handleBack() { - if (get(viewMode) === "form") goDetail(get(selectedEntryId)); + if (get(viewMode) === "form" || get(viewMode) === "settings") goList(); else goList(); } async function handleEmptyTrash() { @@ -7216,16 +7386,20 @@ function MainLayout($$anchor, $$props) { template_effect(() => set_text(text_1, get(selectedEntryId) ? "Edit Entry" : "New Entry")); append($$anchor, h1_2); }; + var consequent_5 = ($$anchor) => { + append($$anchor, root_6()); + }; if_block(node_3, ($$render) => { if (get(viewMode) === "list") $$render(consequent_2); else if (get(viewMode) === "detail") $$render(consequent_3, 1); else if (get(viewMode) === "form") $$render(consequent_4, 2); + else if (get(viewMode) === "settings") $$render(consequent_5, 3); }); reset(div_3); var div_4 = sibling(div_3, 2); var node_4 = child(div_4); - var consequent_5 = ($$anchor) => { - var button_4 = root_6(); + var consequent_6 = ($$anchor) => { + var button_4 = root_7(); var text_2 = child(button_4, true); reset(button_4); template_effect(() => { @@ -7236,31 +7410,32 @@ function MainLayout($$anchor, $$props) { append($$anchor, button_4); }; if_block(node_4, ($$render) => { - if (get(viewMode) === "list" && get(isTrashView)) $$render(consequent_5); + if (get(viewMode) === "list" && get(isTrashView)) $$render(consequent_6); }); var node_5 = sibling(node_4, 2); - var consequent_6 = ($$anchor) => { - var button_5 = root_7(); + var consequent_7 = ($$anchor) => { + var button_5 = root_8(); delegated("click", button_5, () => goForm(null)); append($$anchor, button_5); }; if_block(node_5, ($$render) => { - if (get(viewMode) === "list" && !get(isTrashView)) $$render(consequent_6); + if (get(viewMode) === "list" && !get(isTrashView)) $$render(consequent_7); }); var node_6 = sibling(node_5, 2); ImportExport(node_6, {}); var button_6 = sibling(node_6, 2); + var button_7 = sibling(button_6, 2); reset(div_4); reset(div_2); var div_5 = sibling(div_2, 2); var node_7 = child(div_5); - var consequent_7 = ($$anchor) => { + var consequent_8 = ($$anchor) => { EntryList($$anchor, { onSelect: goDetail, onAdd: () => goForm(null) }); }; - var consequent_8 = ($$anchor) => { + var consequent_9 = ($$anchor) => { EntryDetail($$anchor, { get entryId() { return get(selectedEntryId); @@ -7269,7 +7444,7 @@ function MainLayout($$anchor, $$props) { onBack: goList }); }; - var consequent_9 = ($$anchor) => { + var consequent_10 = ($$anchor) => { EntryForm($$anchor, { get entryId() { return get(selectedEntryId); @@ -7278,43 +7453,48 @@ function MainLayout($$anchor, $$props) { onCancel: handleBack }); }; + var consequent_11 = ($$anchor) => { + SettingsDialog($$anchor, { onBack: goList }); + }; if_block(node_7, ($$render) => { - if (get(viewMode) === "list") $$render(consequent_7); - else if (get(viewMode) === "detail" && get(selectedEntryId)) $$render(consequent_8, 1); - else if (get(viewMode) === "form") $$render(consequent_9, 2); + if (get(viewMode) === "list") $$render(consequent_8); + else if (get(viewMode) === "detail" && get(selectedEntryId)) $$render(consequent_9, 1); + else if (get(viewMode) === "form") $$render(consequent_10, 2); + else if (get(viewMode) === "settings") $$render(consequent_11, 3); }); reset(div_5); reset(main); var node_8 = sibling(main, 2); - var consequent_10 = ($$anchor) => { - var div_6 = root_11(); + var consequent_12 = ($$anchor) => { + var div_6 = root_13(); var div_7 = child(div_6); var div_8 = sibling(child(div_7), 4); - var button_7 = child(div_8); - var text_3 = child(button_7, true); - reset(button_7); - var button_8 = sibling(button_7, 2); + var button_8 = child(div_8); + var text_3 = child(button_8, true); + reset(button_8); + var button_9 = sibling(button_8, 2); reset(div_8); reset(div_7); reset(div_6); template_effect(() => { - button_7.disabled = get(emptyingTrash); + button_8.disabled = get(emptyingTrash); set_text(text_3, get(emptyingTrash) ? "Emptying..." : "Yes, empty trash"); }); delegated("click", div_6, () => set(showEmptyTrashConfirm, false)); delegated("click", div_7, (e) => e.stopPropagation()); - delegated("click", button_7, handleEmptyTrash); - delegated("click", button_8, () => set(showEmptyTrashConfirm, false)); + delegated("click", button_8, handleEmptyTrash); + delegated("click", button_9, () => set(showEmptyTrashConfirm, false)); append($$anchor, div_6); }; if_block(node_8, ($$render) => { - if (get(showEmptyTrashConfirm)) $$render(consequent_10); + if (get(showEmptyTrashConfirm)) $$render(consequent_12); }); reset(div); template_effect(() => set_class(aside, 1, `sidebar ${get(sidebarOpen) ? "open" : ""}`, "svelte-1ybayt")); delegated("click", button, () => set(sidebarOpen, !get(sidebarOpen))); delegated("click", button_1, handleLock); - delegated("click", button_6, handleLock); + delegated("click", button_6, goSettings); + delegated("click", button_7, handleLock); append($$anchor, div); pop(); } @@ -8265,6 +8445,76 @@ label { border-color: var(--color-primary); } + .settings-panel.svelte-1koizbb { + max-width: 500px; + } + + .form-card.svelte-1koizbb { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 24px; + } + + .form-card.svelte-1koizbb h3:where(.svelte-1koizbb) { + margin-bottom: 16px; + } + + .form-actions.svelte-1koizbb { + display: flex; + gap: 8px; + margin-top: 20px; + } + + .toggle-label.svelte-1koizbb { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + margin-bottom: 0; + } + + .toggle-label.svelte-1koizbb input[type="checkbox"]:where(.svelte-1koizbb) { + position: absolute; + opacity: 0; + width: 0; + height: 0; + } + + .toggle-track.svelte-1koizbb { + width: 40px; + height: 22px; + background: var(--color-border); + border-radius: 11px; + position: relative; + transition: background-color 150ms; + flex-shrink: 0; + } + + .toggle-track.svelte-1koizbb .toggle-thumb:where(.svelte-1koizbb) { + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + background: #fff; + border-radius: 50%; + transition: transform 150ms; + } + + .toggle-label.svelte-1koizbb input:where(.svelte-1koizbb):checked + .toggle-track:where(.svelte-1koizbb) { + background: var(--color-primary); + } + + .toggle-label.svelte-1koizbb input:where(.svelte-1koizbb):checked + .toggle-track:where(.svelte-1koizbb) .toggle-thumb:where(.svelte-1koizbb) { + transform: translateX(18px); + } + + .toggle-text.svelte-1koizbb { + font-size: 0.875rem; + color: var(--color-text); + } + .app-shell.svelte-1ybayt { display: flex; min-height: 100vh; diff --git a/src/components/LockScreen.svelte b/src/components/LockScreen.svelte index 1305276..d3b89cd 100644 --- a/src/components/LockScreen.svelte +++ b/src/components/LockScreen.svelte @@ -3,6 +3,7 @@ import { deriveKey, createTestPayload, verifyPassword } from '../lib/crypto/crypto.js' import { saveVaultMeta, loadVaultMeta, isVaultInitialized, ensureTrashGroup } from '../lib/storage/db.js' import { startAutoLock } from '../lib/stores/security.svelte.js' + import { settings } from '../lib/stores/settings.svelte.js' import { autofocus } from '../lib/autofocus.js' let masterPassword = $state('') @@ -42,6 +43,7 @@ await saveVaultMeta(salt, testEncrypted, testPlaintext) await ensureTrashGroup() + await settings.load() app.isUnlocked = true startAutoLock() } else { @@ -64,6 +66,7 @@ app.salt = meta.salt app.encryptionKey = key + await settings.load() app.isUnlocked = true startAutoLock() } diff --git a/src/components/MainLayout.svelte b/src/components/MainLayout.svelte index 2fbb060..d64b31c 100644 --- a/src/components/MainLayout.svelte +++ b/src/components/MainLayout.svelte @@ -8,9 +8,10 @@ import EntryDetail from './EntryDetail.svelte' import EntryForm from './EntryForm.svelte' import ImportExport from './ImportExport.svelte' + import SettingsDialog from './SettingsDialog.svelte' let sidebarOpen = $state(false) - let viewMode = $state('list') // 'list' | 'detail' | 'form' + let viewMode = $state('list') // 'list' | 'detail' | 'form' | 'settings' let selectedEntryId = $state(null) let showEmptyTrashConfirm = $state(false) let emptyingTrash = $state(false) @@ -35,9 +36,14 @@ sidebarOpen = false } + function goSettings() { + viewMode = 'settings' + sidebarOpen = false + } + function handleBack() { - if (viewMode === 'form') { - goDetail(selectedEntryId) + if (viewMode === 'form' || viewMode === 'settings') { + goList() } else { goList() } @@ -94,6 +100,8 @@

Entry Details

{:else if viewMode === 'form'}

{selectedEntryId ? 'Edit Entry' : 'New Entry'}

+ {:else if viewMode === 'settings'} +

Settings

{/if}
@@ -106,6 +114,7 @@ {/if} +
@@ -126,6 +135,8 @@ onSave={goList} onCancel={handleBack} /> + {:else if viewMode === 'settings'} + {/if} diff --git a/src/components/SettingsDialog.svelte b/src/components/SettingsDialog.svelte new file mode 100644 index 0000000..520a1c2 --- /dev/null +++ b/src/components/SettingsDialog.svelte @@ -0,0 +1,149 @@ + + +
+
{ e.preventDefault(); handleSave(); }}> +

Settings

+ +
+ + +

+ Vault locks after {minutes} {minutes === 1 ? 'minute' : 'minutes'} of inactivity. +

+
+ +
+ +

+ {lockOnTabSwitch + ? 'The vault locks immediately when you switch to another tab.' + : 'The vault stays unlocked even when you switch tabs.'} +

+
+ +
+ + +
+
+
+ + diff --git a/src/lib/storage/db.js b/src/lib/storage/db.js index 1b4b2fd..3ea50ec 100644 --- a/src/lib/storage/db.js +++ b/src/lib/storage/db.js @@ -117,6 +117,31 @@ export async function isVaultInitialized() { return meta.salt !== null } +// ======================== +// Settings (user preferences in meta store) +// ======================== + +/** + * Save a single setting as a key/value pair in the meta store. + * @param {string} key + * @param {*} value + */ +export async function saveSetting(key, value) { + const db = await getDb() + await db.put('meta', { key: 'setting:' + key, value }) +} + +/** + * Load a single setting from the meta store. + * @param {string} key + * @returns {Promise<*>} The value, or undefined if not set + */ +export async function getSetting(key) { + const db = await getDb() + const row = await db.get('meta', 'setting:' + key) + return row?.value ?? undefined +} + // ======================== // Groups // ======================== diff --git a/src/lib/stores/security.svelte.js b/src/lib/stores/security.svelte.js index 2ef808d..1b4bc6d 100644 --- a/src/lib/stores/security.svelte.js +++ b/src/lib/stores/security.svelte.js @@ -3,24 +3,26 @@ */ import { app } from './app.svelte.js' - -const DEFAULT_AUTO_LOCK_MINUTES = 5 +import { settings } from './settings.svelte.js' let autoLockTimer = null -let autoLockMinutes = 5 + +// Activity listeners — registered once, cleaned up on stop +let activityHandler = null +let activityEvents = null /** - * Start the auto-lock timer. Resets every time the user interacts. - * @param {number} minutes - Auto-lock after N minutes of inactivity + * Start (or restart) the auto-lock timer using current settings. */ -export function startAutoLock(minutes = DEFAULT_AUTO_LOCK_MINUTES) { - autoLockMinutes = minutes +export function startAutoLock() { resetAutoLock() // Listen for user activity to reset the timer - const events = ['mousedown', 'keydown', 'scroll', 'touchstart'] - const handler = () => resetAutoLock() - events.forEach(evt => window.addEventListener(evt, handler, { passive: true })) + if (!activityHandler) { + activityEvents = ['mousedown', 'keydown', 'scroll', 'touchstart'] + activityHandler = () => resetAutoLock() + activityEvents.forEach(evt => window.addEventListener(evt, activityHandler, { passive: true })) + } // Listen for visibility change (user switches tabs) document.addEventListener('visibilitychange', handleVisibilityChange) @@ -29,29 +31,26 @@ export function startAutoLock(minutes = DEFAULT_AUTO_LOCK_MINUTES) { window.addEventListener('beforeunload', clearKeyOnExit) // Store cleanup function - window.__vaultCleanup = () => { - events.forEach(evt => window.removeEventListener(evt, handler)) - document.removeEventListener('visibilitychange', handleVisibilityChange) - window.removeEventListener('beforeunload', clearKeyOnExit) - if (autoLockTimer) clearTimeout(autoLockTimer) - } + window.__vaultCleanup = stopAutoLock } /** - * Reset the auto-lock timer. + * Reset the auto-lock timer using the current settings value. */ function resetAutoLock() { if (autoLockTimer) clearTimeout(autoLockTimer) + const minutes = settings.autoLockMinutes ?? 5 autoLockTimer = setTimeout(() => { app.lockVault() - }, autoLockMinutes * 60 * 1000) + }, minutes * 60 * 1000) } /** - * Handle visibility change — lock when user switches away from tab. + * Handle visibility change — lock when user switches away from tab + * (only if lockOnTabSwitch is enabled). */ function handleVisibilityChange() { - if (document.hidden && app.isUnlocked) { + if (document.hidden && app.isUnlocked && settings.lockOnTabSwitch) { app.lockVault() } } @@ -64,13 +63,22 @@ function clearKeyOnExit() { } /** - * Stop auto-lock (e.g., when manually locking). + * Stop auto-lock and remove all listeners. */ export function stopAutoLock() { if (autoLockTimer) { clearTimeout(autoLockTimer) autoLockTimer = null } + + if (activityHandler && activityEvents) { + activityEvents.forEach(evt => window.removeEventListener(evt, activityHandler)) + activityHandler = null + activityEvents = null + } + + document.removeEventListener('visibilitychange', handleVisibilityChange) + window.removeEventListener('beforeunload', clearKeyOnExit) } /** diff --git a/src/lib/stores/settings.svelte.js b/src/lib/stores/settings.svelte.js new file mode 100644 index 0000000..5ee613c --- /dev/null +++ b/src/lib/stores/settings.svelte.js @@ -0,0 +1,35 @@ +/** + * Reactive settings store for vault security preferences. + * + * Settings are persisted in IndexedDB (meta store) so they survive + * page reloads. Defaults are used until the user explicitly saves. + */ + +import { getSetting, saveSetting } from '../storage/db.js' + +export class SettingsStore { + autoLockMinutes = $state(5) + lockOnTabSwitch = $state(true) + + /** + * Load persisted settings from IndexedDB. + * Falls back to defaults for any missing keys. + */ + async load() { + const minutes = await getSetting('autoLockMinutes') + const tabSwitch = await getSetting('lockOnTabSwitch') + + this.autoLockMinutes = minutes != null ? Number(minutes) : 5 + this.lockOnTabSwitch = tabSwitch != null ? Boolean(tabSwitch) : true + } + + /** + * Persist current settings to IndexedDB. + */ + async save() { + await saveSetting('autoLockMinutes', this.autoLockMinutes) + await saveSetting('lockOnTabSwitch', this.lockOnTabSwitch) + } +} + +export const settings = new SettingsStore()