Add lock settings.

This commit is contained in:
Timothy Farrell 2026-05-17 17:46:07 +00:00
parent 9d9e599c09
commit 5a240b081d
7 changed files with 854 additions and 373 deletions

952
dist/index.html vendored

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@
import { deriveKey, createTestPayload, verifyPassword } from '../lib/crypto/crypto.js' import { deriveKey, createTestPayload, verifyPassword } from '../lib/crypto/crypto.js'
import { saveVaultMeta, loadVaultMeta, isVaultInitialized, ensureTrashGroup } from '../lib/storage/db.js' import { saveVaultMeta, loadVaultMeta, isVaultInitialized, ensureTrashGroup } from '../lib/storage/db.js'
import { startAutoLock } from '../lib/stores/security.svelte.js' import { startAutoLock } from '../lib/stores/security.svelte.js'
import { settings } from '../lib/stores/settings.svelte.js'
import { autofocus } from '../lib/autofocus.js' import { autofocus } from '../lib/autofocus.js'
let masterPassword = $state('') let masterPassword = $state('')
@ -42,6 +43,7 @@
await saveVaultMeta(salt, testEncrypted, testPlaintext) await saveVaultMeta(salt, testEncrypted, testPlaintext)
await ensureTrashGroup() await ensureTrashGroup()
await settings.load()
app.isUnlocked = true app.isUnlocked = true
startAutoLock() startAutoLock()
} else { } else {
@ -64,6 +66,7 @@
app.salt = meta.salt app.salt = meta.salt
app.encryptionKey = key app.encryptionKey = key
await settings.load()
app.isUnlocked = true app.isUnlocked = true
startAutoLock() startAutoLock()
} }

View File

@ -8,9 +8,10 @@
import EntryDetail from './EntryDetail.svelte' import EntryDetail from './EntryDetail.svelte'
import EntryForm from './EntryForm.svelte' import EntryForm from './EntryForm.svelte'
import ImportExport from './ImportExport.svelte' import ImportExport from './ImportExport.svelte'
import SettingsDialog from './SettingsDialog.svelte'
let sidebarOpen = $state(false) let sidebarOpen = $state(false)
let viewMode = $state('list') // 'list' | 'detail' | 'form' let viewMode = $state('list') // 'list' | 'detail' | 'form' | 'settings'
let selectedEntryId = $state(null) let selectedEntryId = $state(null)
let showEmptyTrashConfirm = $state(false) let showEmptyTrashConfirm = $state(false)
let emptyingTrash = $state(false) let emptyingTrash = $state(false)
@ -35,9 +36,14 @@
sidebarOpen = false sidebarOpen = false
} }
function goSettings() {
viewMode = 'settings'
sidebarOpen = false
}
function handleBack() { function handleBack() {
if (viewMode === 'form') { if (viewMode === 'form' || viewMode === 'settings') {
goDetail(selectedEntryId) goList()
} else { } else {
goList() goList()
} }
@ -94,6 +100,8 @@
<h1>Entry Details</h1> <h1>Entry Details</h1>
{:else if viewMode === 'form'} {:else if viewMode === 'form'}
<h1>{selectedEntryId ? 'Edit Entry' : 'New Entry'}</h1> <h1>{selectedEntryId ? 'Edit Entry' : 'New Entry'}</h1>
{:else if viewMode === 'settings'}
<h1>Settings</h1>
{/if} {/if}
</div> </div>
<div class="top-bar-actions"> <div class="top-bar-actions">
@ -106,6 +114,7 @@
<button class="btn btn-primary btn-sm" onclick={() => goForm(null)}>+ New Entry</button> <button class="btn btn-primary btn-sm" onclick={() => goForm(null)}>+ New Entry</button>
{/if} {/if}
<ImportExport /> <ImportExport />
<button class="btn btn-ghost btn-sm" onclick={goSettings} title="Settings">⚙️</button>
<button class="btn btn-ghost btn-sm" onclick={handleLock} title="Lock vault">🔒</button> <button class="btn btn-ghost btn-sm" onclick={handleLock} title="Lock vault">🔒</button>
</div> </div>
</div> </div>
@ -126,6 +135,8 @@
onSave={goList} onSave={goList}
onCancel={handleBack} onCancel={handleBack}
/> />
{:else if viewMode === 'settings'}
<SettingsDialog onBack={goList} />
{/if} {/if}
</div> </div>
</main> </main>

View File

@ -0,0 +1,149 @@
<script>
import { settings } from '../lib/stores/settings.svelte.js'
import { startAutoLock } from '../lib/stores/security.svelte.js'
let { onBack } = $props()
// Local copies so the user can cancel without losing values
let minutes = $state(settings.autoLockMinutes)
let lockOnTabSwitch = $state(settings.lockOnTabSwitch)
let saving = $state(false)
const minuteOptions = [1, 5, 10, 15, 30, 60]
async function handleSave() {
saving = true
try {
settings.autoLockMinutes = minutes
settings.lockOnTabSwitch = lockOnTabSwitch
await settings.save()
startAutoLock()
} catch (e) {
console.error('Failed to save settings:', e)
}
saving = false
onBack()
}
// Sync local values on mount
$effect(() => {
minutes = settings.autoLockMinutes
lockOnTabSwitch = settings.lockOnTabSwitch
})
</script>
<div class="settings-panel">
<form class="form-card" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<h3>Settings</h3>
<div class="form-group">
<label for="auto-lock-minutes">Auto-lock after</label>
<select id="auto-lock-minutes" bind:value={minutes}>
{#each minuteOptions as m}
<option value={m}>{m} {m === 1 ? 'minute' : 'minutes'}</option>
{/each}
</select>
<p class="text-muted text-xs mt-1">
Vault locks after {minutes} {minutes === 1 ? 'minute' : 'minutes'} of inactivity.
</p>
</div>
<div class="form-group">
<label class="toggle-label" for="lock-tab-switch">
<input
id="lock-tab-switch"
type="checkbox"
bind:checked={lockOnTabSwitch}
/>
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
<span class="toggle-text">Lock when tab loses focus</span>
</label>
<p class="text-muted text-xs mt-1">
{lockOnTabSwitch
? 'The vault locks immediately when you switch to another tab.'
: 'The vault stays unlocked even when you switch tabs.'}
</p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>
<button type="button" class="btn btn-ghost" onclick={onBack}>Cancel</button>
</div>
</form>
</div>
<style>
.settings-panel {
max-width: 500px;
}
.form-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 24px;
}
.form-card h3 {
margin-bottom: 16px;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 20px;
}
.toggle-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
margin-bottom: 0;
}
.toggle-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-track {
width: 40px;
height: 22px;
background: var(--color-border);
border-radius: 11px;
position: relative;
transition: background-color 150ms;
flex-shrink: 0;
}
.toggle-track .toggle-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
transition: transform 150ms;
}
.toggle-label input:checked + .toggle-track {
background: var(--color-primary);
}
.toggle-label input:checked + .toggle-track .toggle-thumb {
transform: translateX(18px);
}
.toggle-text {
font-size: 0.875rem;
color: var(--color-text);
}
</style>

View File

@ -117,6 +117,31 @@ export async function isVaultInitialized() {
return meta.salt !== null 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 // Groups
// ======================== // ========================

View File

@ -3,24 +3,26 @@
*/ */
import { app } from './app.svelte.js' import { app } from './app.svelte.js'
import { settings } from './settings.svelte.js'
const DEFAULT_AUTO_LOCK_MINUTES = 5
let autoLockTimer = null 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. * Start (or restart) the auto-lock timer using current settings.
* @param {number} minutes - Auto-lock after N minutes of inactivity
*/ */
export function startAutoLock(minutes = DEFAULT_AUTO_LOCK_MINUTES) { export function startAutoLock() {
autoLockMinutes = minutes
resetAutoLock() resetAutoLock()
// Listen for user activity to reset the timer // Listen for user activity to reset the timer
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'] if (!activityHandler) {
const handler = () => resetAutoLock() activityEvents = ['mousedown', 'keydown', 'scroll', 'touchstart']
events.forEach(evt => window.addEventListener(evt, handler, { passive: true })) activityHandler = () => resetAutoLock()
activityEvents.forEach(evt => window.addEventListener(evt, activityHandler, { passive: true }))
}
// Listen for visibility change (user switches tabs) // Listen for visibility change (user switches tabs)
document.addEventListener('visibilitychange', handleVisibilityChange) document.addEventListener('visibilitychange', handleVisibilityChange)
@ -29,29 +31,26 @@ export function startAutoLock(minutes = DEFAULT_AUTO_LOCK_MINUTES) {
window.addEventListener('beforeunload', clearKeyOnExit) window.addEventListener('beforeunload', clearKeyOnExit)
// Store cleanup function // Store cleanup function
window.__vaultCleanup = () => { window.__vaultCleanup = stopAutoLock
events.forEach(evt => window.removeEventListener(evt, handler))
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('beforeunload', clearKeyOnExit)
if (autoLockTimer) clearTimeout(autoLockTimer)
}
} }
/** /**
* Reset the auto-lock timer. * Reset the auto-lock timer using the current settings value.
*/ */
function resetAutoLock() { function resetAutoLock() {
if (autoLockTimer) clearTimeout(autoLockTimer) if (autoLockTimer) clearTimeout(autoLockTimer)
const minutes = settings.autoLockMinutes ?? 5
autoLockTimer = setTimeout(() => { autoLockTimer = setTimeout(() => {
app.lockVault() 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() { function handleVisibilityChange() {
if (document.hidden && app.isUnlocked) { if (document.hidden && app.isUnlocked && settings.lockOnTabSwitch) {
app.lockVault() 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() { export function stopAutoLock() {
if (autoLockTimer) { if (autoLockTimer) {
clearTimeout(autoLockTimer) clearTimeout(autoLockTimer)
autoLockTimer = null autoLockTimer = null
} }
if (activityHandler && activityEvents) {
activityEvents.forEach(evt => window.removeEventListener(evt, activityHandler))
activityHandler = null
activityEvents = null
}
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('beforeunload', clearKeyOnExit)
} }
/** /**

View File

@ -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()