diff --git a/e2e/auto-lock.test.ts b/e2e/auto-lock.test.ts new file mode 100644 index 0000000..d2e10c0 --- /dev/null +++ b/e2e/auto-lock.test.ts @@ -0,0 +1,87 @@ +/** Auto-lock depends on the embedded app seeing the tab go away — but the app + * runs in an iframe, and only the top-level tab is ever hidden or shown. That + * a frame's visibilityState follows its tab is a real-browser fact jsdom has + * no way to demonstrate, so this drives a second tab in front of the first + * and waits for the database to lock itself. */ +import assert from 'node:assert/strict'; +import { basename } from 'node:path'; +import { after, before, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import puppeteer, { type Browser, type ElementHandle, type Page } from 'puppeteer-core'; +import { resolveChromePath } from './support/chrome.ts'; +import { type DistServer, startDistServer } from './support/dist-server.ts'; +import { writeKdbxFixture } from './support/fixture.ts'; +import { resolveLaunchOptions } from './support/launch-options.ts'; + +const distDir = fileURLToPath(new URL('../dist', import.meta.url)); +// The settings dialog's floor, chosen here so the wait below is ten seconds +// rather than the thirty a fresh session defaults to. +const DELAY_SECONDS = 10; + +let server: DistServer; +let browser: Browser; +let page: Page; + +before(async () => { + server = await startDistServer(distDir); + browser = await puppeteer.launch({ + executablePath: resolveChromePath(), + ...resolveLaunchOptions(), + args: ['--no-sandbox'], + }); + page = await browser.newPage(); +}); + +after(async () => { + await browser.close(); + await server.close(); +}); + +test('a tab left hidden locks the embedded database on its own', async () => { + const fixture = await writeKdbxFixture(); + + await page.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' }); + + // waitForSelector can't infer the element type from an id selector. + const fileInput = (await page.waitForSelector('#file-input')) as ElementHandle; + assert.ok(fileInput, 'the file input exists'); + await fileInput.uploadFile(fixture.path); + + const iframeElement = await page.waitForSelector('#app-frame'); + assert.ok(iframeElement, 'the app is embedded'); + const frame = await iframeElement.contentFrame(); + assert.ok(frame, 'the iframe has a content frame'); + + const passwordInput = await frame.waitForSelector('#master-password'); + assert.ok(passwordInput, 'the embedded app is on its unlock screen'); + await passwordInput.type(fixture.password); + await frame.click('#unlock-btn'); + await frame.waitForSelector('.entry-table'); + + await frame.click('[data-action="settings"]'); + await frame.waitForFunction( + () => document.querySelector('#dlg-settings')?.open === true, + ); + const delayInput = (await frame.waitForSelector( + '#auto-lock-timeout', + )) as ElementHandle; + assert.ok(delayInput, 'the settings dialog offers the auto-lock delay'); + await delayInput.evaluate((el, seconds: number) => { + el.value = String(seconds); + }, DELAY_SECONDS); + await frame.click('#dlg-settings [data-action="save-settings"]'); + + // Somewhere else is now in front, so the database's tab is hidden. + const otherTab = await browser.newPage(); + await otherTab.bringToFront(); + + await frame.waitForSelector('#master-password', { timeout: (DELAY_SECONDS + 20) * 1000 }); + + await page.bringToFront(); + await otherTab.close(); + assert.equal( + await page.title(), + `🔒 ${basename(fixture.path)} - KeePass Web - Local file`, + 'and the tab bar shows it locked, without being opened', + ); +}); diff --git a/e2e/local-to-app-embed.test.ts b/e2e/local-to-app-embed.test.ts index b4b349b..c3b9dd8 100644 --- a/e2e/local-to-app-embed.test.ts +++ b/e2e/local-to-app-embed.test.ts @@ -29,6 +29,10 @@ before(async () => { args: ['--no-sandbox'], }); page = await browser.newPage(); + // The second test navigates away from the database the first one left + // unlocked, and an open database now makes Chrome ask first (#66). That + // prompt is the subject of navigation-guard.test.ts; here it is in the way. + page.on('dialog', (dialog) => void dialog.accept()); }); after(async () => { diff --git a/e2e/navigation-guard.test.ts b/e2e/navigation-guard.test.ts new file mode 100644 index 0000000..f158e2a --- /dev/null +++ b/e2e/navigation-guard.test.ts @@ -0,0 +1,74 @@ +/** Whether a descendant frame's beforeunload actually blocks a top-level + * reload or back is a real-browser question: the guard lives in the embedded + * 0x67 app, but the navigation belongs to local.html, and jsdom has no + * navigation to block. Chrome also only honors the guard once the frame has + * user activation, which no unit test can produce. */ +import assert from 'node:assert/strict'; +import { after, before, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import puppeteer, { type Browser, type ElementHandle, type Page } from 'puppeteer-core'; +import { resolveChromePath } from './support/chrome.ts'; +import { type DistServer, startDistServer } from './support/dist-server.ts'; +import { writeKdbxFixture } from './support/fixture.ts'; +import { resolveLaunchOptions } from './support/launch-options.ts'; + +const distDir = fileURLToPath(new URL('../dist', import.meta.url)); + +let server: DistServer; +let browser: Browser; +let page: Page; + +before(async () => { + server = await startDistServer(distDir); + browser = await puppeteer.launch({ + executablePath: resolveChromePath(), + ...resolveLaunchOptions(), + args: ['--no-sandbox'], + }); + page = await browser.newPage(); +}); + +after(async () => { + await browser.close(); + await server.close(); +}); + +test('an open database makes the browser ask before it reloads the tab', async () => { + const fixture = await writeKdbxFixture(); + + await page.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' }); + + // waitForSelector can't infer the element type from an id selector. + const fileInput = (await page.waitForSelector('#file-input')) as ElementHandle; + assert.ok(fileInput, 'the file input exists'); + await fileInput.uploadFile(fixture.path); + + const iframeElement = await page.waitForSelector('#app-frame'); + assert.ok(iframeElement, 'the app is embedded'); + const iframeFrame = await iframeElement.contentFrame(); + assert.ok(iframeFrame, 'the iframe has a content frame'); + + // Typing and clicking here is also what gives the frame the user activation + // Chrome requires before honoring its beforeunload at all. + const passwordInput = await iframeFrame.waitForSelector('#master-password'); + assert.ok(passwordInput, 'the embedded app is on its unlock screen'); + await passwordInput.type(fixture.password); + await iframeFrame.click('#unlock-btn'); + await iframeFrame.waitForSelector('.entry-table'); + + const prompts: string[] = []; + page.on('dialog', async (dialog) => { + prompts.push(dialog.type()); + // Accept, so the reload proceeds and this test never waits on a + // navigation that was cancelled out from under it. + await dialog.accept(); + }); + + await page.reload({ waitUntil: 'networkidle0' }); + assert.deepEqual(prompts, ['beforeunload'], 'an open database is worth asking about'); + + // The reload landed back on an empty chooser, so there is nothing to lose. + await page.waitForSelector('#drop-zone'); + await page.reload({ waitUntil: 'networkidle0' }); + assert.equal(prompts.length, 1, 'a tab holding no database reloads without a word'); +}); diff --git a/e2e/tab-title.test.ts b/e2e/tab-title.test.ts new file mode 100644 index 0000000..acdbaca --- /dev/null +++ b/e2e/tab-title.test.ts @@ -0,0 +1,89 @@ +/** The tab title belongs to local.html, but only the embedded 0x67 app knows + * which database is open and whether it is locked — so the title is right + * only if a real cross-document postMessage is delivered and handled. The + * jsdom suites test each page in its own isolated window and cannot show + * that; this drives the built distributables in Chrome, where the two + * documents really are separate. */ +import assert from 'node:assert/strict'; +import { basename } from 'node:path'; +import { after, before, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import puppeteer, { type Browser, type ElementHandle, type Page } from 'puppeteer-core'; +import { resolveChromePath } from './support/chrome.ts'; +import { type DistServer, startDistServer } from './support/dist-server.ts'; +import { writeKdbxFixture } from './support/fixture.ts'; +import { resolveLaunchOptions } from './support/launch-options.ts'; + +const distDir = fileURLToPath(new URL('../dist', import.meta.url)); +const BASE_TITLE = 'KeePass Web - Local file'; + +let server: DistServer; +let browser: Browser; +let page: Page; + +before(async () => { + server = await startDistServer(distDir); + browser = await puppeteer.launch({ + executablePath: resolveChromePath(), + ...resolveLaunchOptions(), + args: ['--no-sandbox'], + }); + page = await browser.newPage(); +}); + +after(async () => { + await browser.close(); + await server.close(); +}); + +/** The title lands a turn after the DOM change that triggers it, once the + * iframe's message has crossed to the host. On timeout, re-assert so the + * failure names the title that was actually there instead of just "timed out". */ +async function waitForTitle(expected: string): Promise { + try { + await page.waitForFunction( + (want: string) => document.title === want, + { timeout: 5000 }, + expected, + ); + } catch { + assert.equal(await page.title(), expected); + } +} + +test('the tab names the open database and tracks its lock state', async () => { + const fixture = await writeKdbxFixture(); + const filename = basename(fixture.path); + + await page.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' }); + assert.equal(await page.title(), BASE_TITLE, 'nothing open, so the tab is just this page'); + + // waitForSelector can't infer the element type from an id selector. + const fileInput = (await page.waitForSelector('#file-input')) as ElementHandle; + assert.ok(fileInput, 'the file input exists'); + await fileInput.uploadFile(fixture.path); + + const iframeElement = await page.waitForSelector('#app-frame'); + assert.ok(iframeElement, 'a recognized file embeds the app in an iframe'); + const iframeFrame = await iframeElement.contentFrame(); + assert.ok(iframeFrame, 'the iframe has a content frame'); + + await waitForTitle(`🔒 ${filename} - ${BASE_TITLE}`); + + const passwordInput = await iframeFrame.waitForSelector('#master-password'); + assert.ok(passwordInput, 'the embedded app went straight to its unlock screen'); + await passwordInput.type(fixture.password); + await iframeFrame.click('#unlock-btn'); + + await iframeFrame.waitForSelector('.entry-table'); + await waitForTitle(`🔓 ${filename} - ${BASE_TITLE}`); + + // Nothing is unsaved, but the app still asks before giving the database up. + await page.click('[data-action="back-to-chooser"]'); + await iframeFrame.waitForFunction( + () => document.querySelector('#dlg-confirm-discard')?.open === true, + ); + await iframeFrame.click('#dlg-confirm-discard [data-action="confirm-discard"]'); + await page.waitForSelector('#drop-zone'); + await waitForTitle(BASE_TITLE); +}); diff --git a/packages/embed-protocol/src/index.ts b/packages/embed-protocol/src/index.ts index 0b7c73f..14792f6 100644 --- a/packages/embed-protocol/src/index.ts +++ b/packages/embed-protocol/src/index.ts @@ -2,7 +2,7 @@ keepass-web implementation and whatever host embeds it in an iframe. Centralizes shapes/guards/builders (previously duplicated per side) so both ends provably agree on the wire format: kw-ready, kw-open, kw-create, -kw-save, kw-saved, kw-close-request, kw-close-ack, kw-close. */ +kw-save, kw-saved, kw-title, kw-close-request, kw-close-ack, kw-close. */ export interface ReadyMessage { type: 'kw-ready'; @@ -31,6 +31,13 @@ export interface SavedMessage { error?: string; } +// The host document owns the tab title, so the app reports state rather than setting it (#65). +export interface TitleMessage { + type: 'kw-title'; + filename: string; + locked: boolean; +} + export interface CloseRequestMessage { type: 'kw-close-request'; } @@ -83,6 +90,12 @@ export function isSavedMessage(data: unknown): data is SavedMessage { return rec.error === undefined || typeof rec.error === 'string'; } +export function isTitleMessage(data: unknown): data is TitleMessage { + if (!hasType(data, 'kw-title')) return false; + const rec = data as Record; + return typeof rec.filename === 'string' && typeof rec.locked === 'boolean'; +} + export function isCloseRequestMessage(data: unknown): data is CloseRequestMessage { return hasType(data, 'kw-close-request'); } @@ -117,6 +130,10 @@ export function savedMessage(ok: boolean, error?: string): SavedMessage { return error === undefined ? { type: 'kw-saved', ok } : { type: 'kw-saved', ok, error }; } +export function titleMessage(filename: string, locked: boolean): TitleMessage { + return { type: 'kw-title', filename, locked }; +} + export function closeRequestMessage(): CloseRequestMessage { return { type: 'kw-close-request' }; } diff --git a/packages/embed-protocol/tests/index.test.ts b/packages/embed-protocol/tests/index.test.ts index b6defbc..6267d75 100644 --- a/packages/embed-protocol/tests/index.test.ts +++ b/packages/embed-protocol/tests/index.test.ts @@ -13,10 +13,12 @@ import { isReadyMessage, isSavedMessage, isSaveMessage, + isTitleMessage, openMessage, readyMessage, savedMessage, saveMessage, + titleMessage, } from '../src/index.ts'; test('readyMessage / isReadyMessage round-trip', () => { @@ -70,6 +72,18 @@ test('savedMessage / isSavedMessage round-trip, with and without an error', () = assert.equal(isSavedMessage({ type: 'kw-saved', ok: true, error: 42 }), false); }); +test('titleMessage / isTitleMessage round-trip', () => { + assert.deepEqual(titleMessage('vault.kdbx', true), { + type: 'kw-title', + filename: 'vault.kdbx', + locked: true, + }); + assert.equal(isTitleMessage(titleMessage('vault.kdbx', false)), true); + assert.equal(isTitleMessage(null), false); + assert.equal(isTitleMessage({ type: 'kw-title', filename: 'vault.kdbx' }), false); + assert.equal(isTitleMessage({ type: 'kw-title', filename: 42, locked: true }), false); +}); + test('closeRequestMessage / isCloseRequestMessage round-trip', () => { assert.deepEqual(closeRequestMessage(), { type: 'kw-close-request' }); assert.equal(isCloseRequestMessage(closeRequestMessage()), true); diff --git a/pages/0x67/bundle-iife.json b/pages/0x67/bundle-iife.json index ab4a0f5..b692584 100644 --- a/pages/0x67/bundle-iife.json +++ b/pages/0x67/bundle-iife.json @@ -75,6 +75,7 @@ "applyEntryEdits", "isCustomField", "isValidClipboardTimeout", + "isValidAutoLockTimeout", "generatePassword", "elementIconId", "iconEmoji", @@ -89,6 +90,7 @@ "isCloseRequestMessage", "readyMessage", "saveMessage", + "titleMessage", "closeAckMessage", "closeMessage" ] diff --git a/pages/0x67/globals.d.ts b/pages/0x67/globals.d.ts index 098795e..923c7b1 100644 --- a/pages/0x67/globals.d.ts +++ b/pages/0x67/globals.d.ts @@ -33,6 +33,11 @@ interface SavedMessage { ok: boolean; error?: string; } +interface TitleMessage { + type: 'kw-title'; + filename: string; + locked: boolean; +} interface CloseRequestMessage { type: 'kw-close-request'; } @@ -52,6 +57,7 @@ declare function isSavedMessage(data: unknown): data is SavedMessage; declare function isCloseRequestMessage(data: unknown): data is CloseRequestMessage; declare function readyMessage(): ReadyMessage; declare function saveMessage(filename: string, bytes: ArrayBuffer): SaveMessage; +declare function titleMessage(filename: string, locked: boolean): TitleMessage; declare function closeAckMessage(): CloseAckMessage; declare function closeMessage(): CloseMessage; @@ -195,6 +201,7 @@ interface EditedField { declare function applyEntryEdits(entry: XmlElement, fields: EditedField[]): void; declare function isCustomField(key: string): boolean; declare function isValidClipboardTimeout(seconds: number): boolean; +declare function isValidAutoLockTimeout(seconds: number): boolean; interface PasswordGeneratorOptions { length: number; diff --git a/pages/0x67/logic.ts b/pages/0x67/logic.ts index 514f65e..04773ff 100644 --- a/pages/0x67/logic.ts +++ b/pages/0x67/logic.ts @@ -309,6 +309,11 @@ export function isValidClipboardTimeout(seconds: number): boolean { return !Number.isNaN(seconds) && seconds >= 5; } +/** The settings dialog's minimum accepted auto-lock delay, in seconds. */ +export function isValidAutoLockTimeout(seconds: number): boolean { + return !Number.isNaN(seconds) && seconds >= 10; +} + /** Character classes offered by the password generator. */ const GENERATOR_CHARSETS = { upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', diff --git a/pages/0x67/page.html b/pages/0x67/page.html index fa0d33f..41abba9 100644 --- a/pages/0x67/page.html +++ b/pages/0x67/page.html @@ -236,6 +236,10 @@

Settings

+
+ + +

diff --git a/pages/0x67/page.ts b/pages/0x67/page.ts index 1d5d489..7292ca6 100644 --- a/pages/0x67/page.ts +++ b/pages/0x67/page.ts @@ -10,6 +10,7 @@ interface AppState { currentEntry: XmlElement | null; searchQuery: string; clipboardTimeout: number; // seconds + autoLockTimeout: number; // seconds hidden before the database locks itself dirty: boolean; // unsaved edits exist sortField: EntrySortField; sortDir: EntrySortDirection; @@ -25,6 +26,7 @@ const app: AppState = { currentEntry: null, searchQuery: '', clipboardTimeout: 30, + autoLockTimeout: 30, dirty: false, sortField: 'title', sortDir: 'asc', @@ -44,6 +46,11 @@ const app: AppState = { // True once a trusted parent frame has handed this app a vault (see "Host integration"). let hostSession = false; +// True while this tab holds a database: loaded, newly created, or locked (#66). +function hasDatabase(): boolean { + return app.db !== null || app.file !== null; +} + // The one place app.dirty changes, so the persistent save indicator can never drift from it. function setDirty(value: boolean): void { app.dirty = value; @@ -164,6 +171,7 @@ async function copyToClipboard(text: string, label = 'Value'): Promise { function showUpload(): void { document.body.classList.remove('app-mode'); setRoot(cloneTemplate('tpl-upload')); + publishTitle(); const dropZone = qs('#drop-zone'); const fileInput = qs('#file-input'); @@ -207,6 +215,7 @@ unsaved yet. */ function showUnlock(preserveDirty = false): void { document.body.classList.remove('app-mode'); setRoot(cloneTemplate('tpl-unlock')); + publishTitle(); qs('#db-filename').textContent = app.filename; const passwordInput = qs('#master-password'); @@ -268,6 +277,7 @@ function showUnlock(preserveDirty = false): void { function showCreateDatabase(): void { document.body.classList.remove('app-mode'); setRoot(cloneTemplate('tpl-create-database')); + publishTitle(); const nameInput = qs('#create-name'); const passwordInput = qs('#create-password'); @@ -336,6 +346,7 @@ function showCreateDatabase(): void { function showEntryList(): void { document.body.classList.add('app-mode'); setRoot(cloneTemplate('tpl-entry-list')); + publishTitle(); renderGroupTree(); renderEntryPanel(); wireEntryListEvents(); @@ -836,10 +847,32 @@ function updateViewToggleUI(): void { qs('#panel-menu').classList.remove('panel-menu-open'); } +let autoLockTimer: ReturnType | null = null; + +function cancelAutoLock(): void { + if (autoLockTimer === null) return; + clearTimeout(autoLockTimer); + autoLockTimer = null; +} + +/* A tab nobody is looking at is an unattended screen: the database sits there +decrypted in memory for as long as it stays that way. Locking re-encrypts the +current state, unsaved edits included, so this can fire on its own without +asking anything of someone who has already walked away (#64). */ +function handleVisibilityChange(): void { + cancelAutoLock(); + if (document.visibilityState !== 'hidden' || app.db === null) return; + autoLockTimer = setTimeout(() => { + autoLockTimer = null; + void lockDatabase(); + }, app.autoLockTimeout * 1000); +} + /** Re-encrypts the current in-memory state (including anything not yet saved) rather than reloading the original file, so locking never loses an edit on its own — only choosing to discard at the prompt above does that. */ async function lockDatabase(): Promise { + cancelAutoLock(); const bytes = await must(app.db).save(); app.file = bytes.buffer as ArrayBuffer; const wasDirty = app.dirty; @@ -848,6 +881,7 @@ async function lockDatabase(): Promise { } function closeDatabase(): void { + cancelAutoLock(); Object.assign(app, { db: null, file: null, @@ -895,7 +929,7 @@ function wireEntryListEvents(): void { }); qs('[data-action="close"]').addEventListener('click', () => { - confirmUnsavedChanges(DISCARD_PROMPT, closeDatabase); + confirmUnsavedChanges(DISCARD_PROMPT, closeDatabase, CLOSE_PROMPT); }); qs('[data-action="settings"]').addEventListener('click', openSettings); @@ -1410,6 +1444,8 @@ function openSettings(): void { const dlg = byId('dlg-settings'); const timeoutInput = byId('clipboard-timeout'); timeoutInput.value = String(app.clipboardTimeout); + const autoLockInput = byId('auto-lock-timeout'); + autoLockInput.value = String(app.autoLockTimeout); const newPasswordInput = byId('settings-new-password'); const confirmInput = byId('settings-new-password-confirm'); @@ -1437,6 +1473,8 @@ function openSettings(): void { must(dlg.querySelector('[data-action="save-settings"]')).onclick = () => { const v = Number.parseInt(timeoutInput.value, 10); if (isValidClipboardTimeout(v)) app.clipboardTimeout = v; + const autoLock = Number.parseInt(autoLockInput.value, 10); + if (isValidAutoLockTimeout(autoLock)) app.autoLockTimeout = autoLock; if (newPasswordInput.value || confirmInput.value) { if (newPasswordInput.value !== confirmInput.value) { @@ -1646,28 +1684,49 @@ const DISCARD_PROMPT: UnsavedChangesPrompt = { discardDanger: true, }; -/** Run `proceed` immediately if there's nothing unsaved to lose. Otherwise -ask: discard (proceed without saving), save first and then proceed, or -cancel (stay put). The one place a user can act on unsaved edits, reused by -every action that would otherwise risk losing or hiding them — closing, -locking, exporting, and a host's close request. */ -function confirmUnsavedChanges(prompt: UnsavedChangesPrompt, proceed: () => void): void { +/* Closing loses no data once everything is saved, so this asks without the +danger styling a real discard gets; it asks at all because reopening the +database can cost another sign-in or another hunt for the file (#66). */ +const CLOSE_PROMPT: UnsavedChangesPrompt = { + title: 'Close this database?', + message: 'Opening it again means finding and unlocking the file.', + discardLabel: 'Close', + discardDanger: false, +}; + +/** Run `proceed` immediately when nothing is at stake. With unsaved edits, +ask: discard, save first, or cancel — reused by closing, locking, exporting, +and a host's close request. `cleanPrompt`, passed only by the paths that give +the database up, asks a cheaper question when nothing is unsaved but +reopening would still cost a sign-in or a hunt for the file (#66). */ +function confirmUnsavedChanges( + prompt: UnsavedChangesPrompt, + proceed: () => void, + cleanPrompt?: UnsavedChangesPrompt, +): void { + let active = prompt; if (!app.dirty) { - proceed(); - return; + if (cleanPrompt === undefined || !hasDatabase()) { + proceed(); + return; + } + active = cleanPrompt; } const dlg = byId('dlg-confirm-discard'); - byId('confirm-discard-title').textContent = prompt.title; - byId('confirm-discard-message').textContent = prompt.message; + byId('confirm-discard-title').textContent = active.title; + byId('confirm-discard-message').textContent = active.message; const status = must(dlg.querySelector('[data-role="confirm-discard-status"]')); status.hidden = true; status.textContent = ''; status.className = 'save-status'; const discardBtn = must(dlg.querySelector('[data-action="confirm-discard"]')); - discardBtn.textContent = prompt.discardLabel; - discardBtn.className = `btn ${prompt.discardDanger ? 'btn-danger' : 'btn-secondary'}`; + discardBtn.textContent = active.discardLabel; + // With no Save beside it, confirming is the dialog's only affirmative action (#66). + let discardStyle = app.dirty ? 'btn-secondary' : 'btn-primary'; + if (active.discardDanger) discardStyle = 'btn-danger'; + discardBtn.className = `btn ${discardStyle}`; discardBtn.disabled = false; discardBtn.onclick = () => { dlg.close(); @@ -1679,6 +1738,7 @@ function confirmUnsavedChanges(prompt: UnsavedChangesPrompt, proceed: () => void const saveBtn = must(dlg.querySelector('[data-action="confirm-save"]')); saveBtn.textContent = hostSession ? 'Save' : 'Download'; + saveBtn.hidden = !app.dirty; // there is nothing to save when the prompt is only about closing saveBtn.disabled = false; saveBtn.onclick = async () => { saveBtn.disabled = true; @@ -1900,6 +1960,9 @@ function openMoveToDialog( const HOST_ORIGIN = window.location.origin; +// This page's own title, kept so a closed database can hand the tab back (#65). +const BASE_TITLE = document.title; + /** Resolves the in-flight performSave()'s promise once a `kw-saved` reply arrives, or null when no save is outstanding. */ let pendingSave: ((result: { ok: boolean; error?: string }) => void) | null = null; @@ -1912,6 +1975,19 @@ function postToHost(message: object): void { window.parent.postMessage(message, HOST_ORIGIN); } +/** The tab title belongs to whichever document owns the tab: the host when +embedded, this page when standalone. Every screen announces itself, so the +tab bar names the open database and its lock state without being opened (#65). */ +function publishTitle(): void { + const locked = app.db === null; + if (isEmbedded()) { + postToHost(titleMessage(app.filename, locked)); + return; + } + const icon = locked ? '🔒' : '🔓'; + document.title = app.filename ? `${icon} ${app.filename} - ${BASE_TITLE}` : BASE_TITLE; +} + function handleHostMessage(event: MessageEvent): void { if (event.origin !== HOST_ORIGIN || event.source !== window.parent) return; @@ -1929,7 +2005,7 @@ function handleHostMessage(event: MessageEvent): void { const { ok, error } = event.data; resolve?.(error === undefined ? { ok } : { ok, error }); } else if (isCloseRequestMessage(event.data)) { - confirmUnsavedChanges(DISCARD_PROMPT, () => postToHost(closeAckMessage())); + confirmUnsavedChanges(DISCARD_PROMPT, () => postToHost(closeAckMessage()), CLOSE_PROMPT); } } @@ -1956,11 +2032,14 @@ if (isEmbedded()) { // leave the app permanently blank instead of at least usable standalone. showUpload(); -// Closing the tab, reloading, or navigating away with unsaved edits would -// otherwise discard them with no warning — there's no autosave to fall back -// on. This is the browser's own native prompt, not a custom dialog. +/* Closing the tab, reloading, or navigating away takes the whole session +with it: unsaved edits, which have no autosave to fall back on, and the file +itself, which cost a sign-in or a hunt for a USB stick to get here (#66). +This is the browser's own native prompt, not a custom dialog. */ window.addEventListener('beforeunload', (e) => { - if (!app.dirty) return; + if (!hasDatabase()) return; e.preventDefault(); e.returnValue = true; }); + +document.addEventListener('visibilitychange', handleVisibilityChange); diff --git a/pages/cloud-google-drive/bundle-iife.json b/pages/cloud-google-drive/bundle-iife.json index 99c3178..05fe91d 100644 --- a/pages/cloud-google-drive/bundle-iife.json +++ b/pages/cloud-google-drive/bundle-iife.json @@ -17,6 +17,7 @@ "buildMultipartBody", "isReadyMessage", "isSaveMessage", + "isTitleMessage", "isCloseAckMessage", "isCloseMessage", "openMessage", diff --git a/pages/cloud-google-drive/globals.d.ts b/pages/cloud-google-drive/globals.d.ts index b44efe0..7977955 100644 --- a/pages/cloud-google-drive/globals.d.ts +++ b/pages/cloud-google-drive/globals.d.ts @@ -45,6 +45,11 @@ interface SavedMessage { ok: boolean; error?: string; } +interface TitleMessage { + type: 'kw-title'; + filename: string; + locked: boolean; +} interface CloseRequestMessage { type: 'kw-close-request'; } @@ -65,6 +70,7 @@ declare function buildMultipartBody( ): { body: Blob; boundary: string }; declare function isReadyMessage(data: unknown): data is ReadyMessage; declare function isSaveMessage(data: unknown): data is SaveMessage; +declare function isTitleMessage(data: unknown): data is TitleMessage; declare function isCloseAckMessage(data: unknown): data is CloseAckMessage; declare function isCloseMessage(data: unknown): data is CloseMessage; declare function openMessage(filename: string, bytes: ArrayBuffer): OpenMessage; diff --git a/pages/cloud-google-drive/page.html b/pages/cloud-google-drive/page.html index 35f0ffa..acf8f68 100644 --- a/pages/cloud-google-drive/page.html +++ b/pages/cloud-google-drive/page.html @@ -3,7 +3,7 @@ - KeePass Web — Google Drive + KeePass Web - Google Drive diff --git a/pages/cloud-google-drive/page.ts b/pages/cloud-google-drive/page.ts index 9848f0f..c6da7ff 100644 --- a/pages/cloud-google-drive/page.ts +++ b/pages/cloud-google-drive/page.ts @@ -28,6 +28,8 @@ const APP_ORIGIN = window.location.origin; // The only current KDBX implementation. Opening detects this from a file's // bytes via packages/router; creating has no bytes to sniff, so it's named directly. const APP_IMPLEMENTATION = '0x67.html'; +// This page's own title, kept so closing the app can hand the tab back (#65). +const BASE_TITLE = document.title; // --- In-memory state (never persisted) ------------------------------------- @@ -262,6 +264,7 @@ function embedApp(headerLabel: string, implementation: string): void { function tearDownIframe(): void { window.removeEventListener('message', handleFrameMessage); + document.title = BASE_TITLE; currentFile = null; pendingAction = null; showChooser(); @@ -300,6 +303,10 @@ function handleFrameMessage(event: MessageEvent): void { } else { void createFileOnDrive(event.data.filename, event.data.bytes, source); } + } else if (isTitleMessage(event.data)) { + const { filename, locked } = event.data; + const icon = locked ? '🔒' : '🔓'; + document.title = filename ? `${icon} ${filename} - ${BASE_TITLE}` : BASE_TITLE; } else if (isCloseAckMessage(event.data)) { const afterClose = pendingClose; pendingClose = null; diff --git a/pages/local/bundle-iife.json b/pages/local/bundle-iife.json index f611e26..7bbec10 100644 --- a/pages/local/bundle-iife.json +++ b/pages/local/bundle-iife.json @@ -12,6 +12,7 @@ "identifyFormat", "isReadyMessage", "isSaveMessage", + "isTitleMessage", "isCloseAckMessage", "isCloseMessage", "openMessage", diff --git a/pages/local/globals.d.ts b/pages/local/globals.d.ts index ca23c80..12b1546 100644 --- a/pages/local/globals.d.ts +++ b/pages/local/globals.d.ts @@ -40,6 +40,11 @@ interface SavedMessage { ok: boolean; error?: string; } +interface TitleMessage { + type: 'kw-title'; + filename: string; + locked: boolean; +} interface CloseRequestMessage { type: 'kw-close-request'; } @@ -52,6 +57,7 @@ interface CloseMessage { declare function isReadyMessage(data: unknown): data is ReadyMessage; declare function isSaveMessage(data: unknown): data is SaveMessage; +declare function isTitleMessage(data: unknown): data is TitleMessage; declare function isCloseAckMessage(data: unknown): data is CloseAckMessage; declare function isCloseMessage(data: unknown): data is CloseMessage; declare function openMessage(filename: string, bytes: ArrayBuffer): OpenMessage; diff --git a/pages/local/page.html b/pages/local/page.html index 279e66a..c918092 100644 --- a/pages/local/page.html +++ b/pages/local/page.html @@ -3,7 +3,7 @@ - KeePass Web — Local file + KeePass Web - Local file diff --git a/pages/local/page.ts b/pages/local/page.ts index d769280..542e520 100644 --- a/pages/local/page.ts +++ b/pages/local/page.ts @@ -9,6 +9,8 @@ const APP_ORIGIN = window.location.origin; // The only current KDBX implementation. Opening detects this from a file's // bytes via packages/router; creating has no bytes to sniff, so it's named directly. const APP_IMPLEMENTATION = '0x67.html'; +// This page's own title, kept so closing the app can hand the tab back (#65). +const BASE_TITLE = document.title; // --- In-memory state (never persisted) ------------------------------------- @@ -128,6 +130,7 @@ function embedApp(headerLabel: string, implementation: string): void { function tearDownIframe(): void { window.removeEventListener('message', handleFrameMessage); + document.title = BASE_TITLE; pendingAction = null; showChooser(); } @@ -156,6 +159,10 @@ function handleFrameMessage(event: MessageEvent): void { } else if (isSaveMessage(event.data)) { qs('#host-filename').textContent = event.data.filename; downloadAndAck(event.data.filename, event.data.bytes, source); + } else if (isTitleMessage(event.data)) { + const { filename, locked } = event.data; + const icon = locked ? '🔒' : '🔓'; + document.title = filename ? `${icon} ${filename} - ${BASE_TITLE}` : BASE_TITLE; } else if (isCloseAckMessage(event.data)) { const afterClose = pendingClose; pendingClose = null; diff --git a/pages/tests/0x67-host.test.ts b/pages/tests/0x67-host.test.ts index 33aba09..9213ba1 100644 --- a/pages/tests/0x67-host.test.ts +++ b/pages/tests/0x67-host.test.ts @@ -194,10 +194,12 @@ const lastHostMessage = (): Record => // ============================================================ test('0x67 embedded in a host frame', async (t) => { - await t.test('announces readiness to the host on boot', () => { - assert.equal(hostInbox.length, 1); + await t.test('announces readiness and an empty title to the host on boot', () => { + assert.equal(hostInbox.length, 2); assert.deepEqual(hostInbox[0]?.message, { type: 'kw-ready' }); assert.equal(hostInbox[0]?.origin, 'https://example.com'); + // Nothing is open yet, so the host is told to keep its own title. + assert.deepEqual(lastHostMessage(), { type: 'kw-title', filename: '', locked: true }); // Still shows the normal upload screen underneath, untouched. assert.ok(q('#drop-zone')); assert.ok(doc.body.classList.contains('embedded')); // suppresses this document's own footer @@ -228,6 +230,14 @@ test('0x67 embedded in a host frame', async (t) => { assert.equal(hostInbox.length, before, 'nothing posted back'); }); + await t.test('kw-close-request with nothing open acks at once', () => { + const before = hostInbox.length; + sendFromHost({ type: 'kw-close-request' }); + assert.equal(hostInbox.length, before + 1, 'no database in the tab, nothing to ask about'); + assert.deepEqual(lastHostMessage(), { type: 'kw-close-ack' }); + assert.equal(dq('#dlg-confirm-discard').open, false); + }); + await t.test('kw-open loads the host-supplied vault into the unlock screen', async () => { sendFromHost({ type: 'kw-open', @@ -236,6 +246,11 @@ test('0x67 embedded in a host frame', async (t) => { }); await waitFor(() => q('#master-password') !== null); assert.equal(q('#db-filename').textContent, 'from-drive.kdbx'); + assert.deepEqual(lastHostMessage(), { + type: 'kw-title', + filename: 'from-drive.kdbx', + locked: true, + }); }); await t.test('unlocks, and the save dialog offers host write-back, not download', async () => { @@ -244,6 +259,11 @@ test('0x67 embedded in a host frame', async (t) => { new dom.window.Event('submit', { bubbles: true, cancelable: true }), ); await waitFor(() => q('#search-input') !== null); + assert.deepEqual(lastHostMessage(), { + type: 'kw-title', + filename: 'from-drive.kdbx', + locked: false, + }); // Make an edit so the save dialog opens: add an entry, then save it. click(q('[data-action="add-entry"]')); @@ -332,14 +352,19 @@ test('0x67 embedded in a host frame', async (t) => { }, ); - await t.test('kw-close-request acks immediately when nothing is dirty', () => { - // The retry above succeeded and its dialog was closed — nothing unsaved - // since then. + await t.test('kw-close-request still asks when the database is saved', () => { + // The retry above succeeded and its dialog was closed, so nothing is + // unsaved — but the open database itself is still worth a question. const before = hostInbox.length; sendFromHost({ type: 'kw-close-request' }); - assert.equal(hostInbox.length, before + 1); + assert.equal(hostInbox.length, before, 'no ack until the user decides'); + const dlg = dq('#dlg-confirm-discard'); + assert.equal(dlg.open, true); + assert.equal(dq('#confirm-discard-title').textContent, 'Close this database?'); + + click(dq('#dlg-confirm-discard [data-action="confirm-discard"]')); + assert.equal(dlg.open, false); assert.deepEqual(lastHostMessage(), { type: 'kw-close-ack' }); - assert.equal(dq('#dlg-confirm-discard').open, false); }); await t.test( @@ -388,7 +413,9 @@ test('0x67 embedded in a host frame: kw-create starts a fresh, empty database', () => { const before = hostInbox.length; sendFromHost({ type: 'kw-create' }); - assert.equal(hostInbox.length, before, 'switching screens needs no round trip to the host'); + assert.equal(hostInbox.length, before + 1, 'only the title, no round trip to the host'); + // Nothing is open until the database is actually created. + assert.deepEqual(lastHostMessage(), { type: 'kw-title', filename: '', locked: true }); assert.ok(q('#create-form')); assert.equal(q('#drop-zone'), null); assert.equal(q('#master-password'), null); diff --git a/pages/tests/0x67-logic.test.ts b/pages/tests/0x67-logic.test.ts index 8f7c334..4f39474 100644 --- a/pages/tests/0x67-logic.test.ts +++ b/pages/tests/0x67-logic.test.ts @@ -29,6 +29,7 @@ import { isCustomField, isDescendantGroup, isoToLocalInputValue, + isValidAutoLockTimeout, isValidClipboardTimeout, localInputValueToIso, sortEntries, @@ -395,6 +396,13 @@ test('isValidClipboardTimeout requires a real number of at least 5 seconds', () assert.equal(isValidClipboardTimeout(Number.NaN), false); }); +test('isValidAutoLockTimeout requires a real number of at least 10 seconds', () => { + assert.equal(isValidAutoLockTimeout(10), true); + assert.equal(isValidAutoLockTimeout(3600), true); + assert.equal(isValidAutoLockTimeout(9), false); + assert.equal(isValidAutoLockTimeout(Number.NaN), false); +}); + test('generatePassword produces a password of the requested length', () => { const password = generatePassword({ length: 32, diff --git a/pages/tests/0x67-page.test.ts b/pages/tests/0x67-page.test.ts index 3538cf5..ca5581a 100644 --- a/pages/tests/0x67-page.test.ts +++ b/pages/tests/0x67-page.test.ts @@ -291,6 +291,7 @@ test('0x67 app', async (t) => { assert.ok(q('#file-input')); assert.equal(dom.window.document.body.classList.contains('app-mode'), false); assert.equal(dom.window.document.body.classList.contains('embedded'), false); + assert.equal(dom.window.document.title, 'KeePass Web'); }); await t.test('dragover/dragleave toggle the drag-over class', () => { @@ -312,11 +313,13 @@ test('0x67 app', async (t) => { await waitFor(() => q('#master-password') !== null); assert.equal(q('#db-filename').textContent, 'dropped.kdbx'); + assert.equal(dom.window.document.title, '🔒 dropped.kdbx - KeePass Web'); }); await t.test('unlock screen "back" returns to upload and clears the file', () => { q('[data-action="back"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); assert.ok(q('#drop-zone')); + assert.equal(dom.window.document.title, 'KeePass Web'); }); await t.test( @@ -425,6 +428,7 @@ test('0x67 app', async (t) => { await waitFor(() => dom.window.document.body.classList.contains('app-mode')); assert.ok(q('#group-tree').querySelector('.group-btn')); + assert.equal(dom.window.document.title, '🔓 real.kdbx - KeePass Web'); // Table view is the default. assert.equal(root().querySelectorAll('.entry-table').length, 1); // Switch to tile view, which the rest of this suite's entry-list @@ -1492,12 +1496,14 @@ test('0x67 app', async (t) => { }, ); - await t.test('settings: a valid timeout is saved, an invalid one is silently ignored', () => { + await t.test('settings: valid timeouts are saved, out-of-range ones are ignored', () => { q('[data-action="settings"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); let dlg = byId('dlg-settings'); assert.equal(byId('clipboard-timeout').value, '30'); + assert.equal(byId('auto-lock-timeout').value, '30'); byId('clipboard-timeout').value = '10'; + byId('auto-lock-timeout').value = '45'; dq('#dlg-settings [data-action="save-settings"]').dispatchEvent( new dom.window.Event('click', { bubbles: true }), ); @@ -1505,7 +1511,9 @@ test('0x67 app', async (t) => { q('[data-action="settings"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); assert.equal(byId('clipboard-timeout').value, '10'); + assert.equal(byId('auto-lock-timeout').value, '45'); byId('clipboard-timeout').value = '2'; + byId('auto-lock-timeout').value = '5'; dq('#dlg-settings [data-action="save-settings"]').dispatchEvent( new dom.window.Event('click', { bubbles: true }), ); @@ -1516,6 +1524,11 @@ test('0x67 app', async (t) => { '10', 'an out-of-range timeout must not overwrite the saved one', ); + assert.equal( + byId('auto-lock-timeout').value, + '45', + 'and neither must an out-of-range auto-lock delay', + ); dlg = byId('dlg-settings'); dq('#dlg-settings [data-action="close"]').dispatchEvent( new dom.window.Event('click', { bubbles: true }), @@ -1597,6 +1610,7 @@ test('0x67 app', async (t) => { assert.equal(lockDlg.open, false); await waitFor(() => q('#master-password') !== null); assert.equal(q('#db-filename').textContent, 'real.kdbx'); + assert.equal(dom.window.document.title, '🔒 real.kdbx - KeePass Web'); // A wrong password on the relocked (freshly re-encrypted) state is // still rejected — locking doesn't weaken the credential check. @@ -1663,14 +1677,42 @@ test('0x67 app', async (t) => { }, ); - await t.test( - 'closing with nothing changed since the save-then-lock skips the confirm dialog', - () => { - q('[data-action="close"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); - assert.ok(q('#drop-zone'), 'closing with nothing unsaved skips the confirm dialog entirely'); - assert.equal(dom.window.document.body.classList.contains('app-mode'), false); - }, - ); + await t.test('closing a saved database still asks, and cancelling leaves it open', () => { + q('[data-action="close"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + const dlg = byId('dlg-confirm-discard'); + assert.equal( + dlg.open, + true, + 'nothing is unsaved, but the open database is still worth a question', + ); + assert.equal(byId('confirm-discard-title').textContent, 'Close this database?'); + const confirmBtn = dq( + '#dlg-confirm-discard [data-action="confirm-discard"]', + ); + assert.equal(confirmBtn.textContent, 'Close'); + assert.equal(confirmBtn.className, 'btn btn-primary', 'the only affirmative action here'); + assert.equal( + dq('#dlg-confirm-discard [data-action="confirm-save"]').hidden, + true, + 'nothing to save, so no Save button', + ); + + dq('#dlg-confirm-discard [data-action="cancel-discard"]').dispatchEvent( + new dom.window.Event('click', { bubbles: true }), + ); + assert.equal(dlg.open, false); + assert.ok(dom.window.document.body.classList.contains('app-mode'), 'still open'); + }); + + await t.test('confirming the close returns to the upload screen and clears the tab', () => { + q('[data-action="close"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + dq('#dlg-confirm-discard [data-action="confirm-discard"]').dispatchEvent( + new dom.window.Event('click', { bubbles: true }), + ); + assert.ok(q('#drop-zone')); + assert.equal(dom.window.document.body.classList.contains('app-mode'), false); + assert.equal(dom.window.document.title, 'KeePass Web'); + }); }); test('closing with unsaved changes prompts to discard, and confirming discards them', async () => { @@ -1714,12 +1756,25 @@ test('closing with unsaved changes prompts to discard, and confirming discards t assert.ok(q('#drop-zone'), 'confirming discard returns to the upload screen'); }); -test('beforeunload is only blocked while there are unsaved edits', async () => { +test('beforeunload is blocked for as long as this tab holds a database', async () => { + const fireBeforeUnload = (): Event => dispatch(dom.window, 'beforeunload'); + assert.equal( + fireBeforeUnload().defaultPrevented, + false, + 'an empty tab has nothing to lose, so it closes freely', + ); + const fileInput = q('#file-input'); setFiles(fileInput, [makeFile('beforeunload-test.kdbx', dbBytes)]); dispatch(fileInput, 'change'); await waitFor(() => q('#master-password') !== null); + assert.equal( + fireBeforeUnload().defaultPrevented, + true, + 'the file is in the tab already; finding it again would cost what it cost the first time', + ); + q('#master-password').value = PASSWORD; const keyfileInput = q('#keyfile-input'); setFiles(keyfileInput, [makeFile('keyfile.bin', KEYFILE)]); @@ -1728,17 +1783,11 @@ test('beforeunload is only blocked while there are unsaved edits', async () => { dispatch(q('#unlock-form'), 'submit'); await waitFor(() => dom.window.document.body.classList.contains('app-mode')); - const fireBeforeUnload = (): Event => dispatch(dom.window, 'beforeunload'); - - assert.equal( - fireBeforeUnload().defaultPrevented, - false, - 'nothing unsaved yet, so the tab may close freely', - ); + assert.equal(fireBeforeUnload().defaultPrevented, true, 'an unlocked database blocks the unload'); q('[data-action="add-entry"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); - assert.equal(fireBeforeUnload().defaultPrevented, true, 'an unsaved edit blocks the unload'); + assert.equal(fireBeforeUnload().defaultPrevented, true, 'and so does an unsaved edit'); // Return to the entry list (the close button lives in its header, not the // entry-edit screen add-entry leaves us on — same recovery as the test @@ -1754,6 +1803,80 @@ test('beforeunload is only blocked while there are unsaved edits', async () => { new dom.window.Event('click', { bubbles: true }), ); assert.ok(q('#drop-zone')); + assert.equal( + fireBeforeUnload().defaultPrevented, + false, + 'a closed database lets the tab go again', + ); +}); + +/** jsdom's visibilityState is a read-only getter, so it is redefined on the + * document instance; the app reads document.visibilityState and listens for + * the event, which is exactly what a real tab switch produces. */ +function setVisibility(state: 'visible' | 'hidden'): void { + Object.defineProperty(dom.window.document, 'visibilityState', { + value: state, + configurable: true, + }); + dispatch(dom.window.document, 'visibilitychange'); +} + +test('a tab left hidden locks itself, and coming back in time calls it off', async (t) => { + const fileInput = q('#file-input'); + setFiles(fileInput, [makeFile('auto-lock.kdbx', dbBytes)]); + dispatch(fileInput, 'change'); + await waitFor(() => q('#master-password') !== null); + + q('#master-password').value = PASSWORD; + const keyfileInput = q('#keyfile-input'); + setFiles(keyfileInput, [makeFile('keyfile.bin', KEYFILE)]); + dispatch(keyfileInput, 'change'); + await waitFor(() => q('#keyfile-label').textContent === 'keyfile.bin'); + dispatch(q('#unlock-form'), 'submit'); + await waitFor(() => dom.window.document.body.classList.contains('app-mode')); + + // Pin the delay, so the ticks below mean something no matter what an + // earlier test left in the settings. + q('[data-action="settings"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + byId('auto-lock-timeout').value = '30'; + dq('#dlg-settings [data-action="save-settings"]').dispatchEvent( + new dom.window.Event('click', { bubbles: true }), + ); + + // Away, then back before the delay is up: the countdown is called off, and + // no amount of later time locks anything. + t.mock.timers.enable({ apis: ['setTimeout'] }); + setVisibility('hidden'); + t.mock.timers.tick(29_000); + setVisibility('visible'); + t.mock.timers.tick(60_000); + t.mock.timers.reset(); + assert.ok(dom.window.document.body.classList.contains('app-mode'), 'still unlocked'); + + // Away for the whole delay: it locks on its own, with nothing to confirm. + t.mock.timers.enable({ apis: ['setTimeout'] }); + setVisibility('hidden'); + t.mock.timers.tick(30_000); + t.mock.timers.reset(); + + await waitFor(() => q('#master-password') !== null); + assert.equal(q('#db-filename').textContent, 'auto-lock.kdbx'); + assert.equal( + dom.window.document.title, + '🔒 auto-lock.kdbx - KeePass Web', + 'the tab bar says so without being opened', + ); + + // Already locked, so hiding again has nothing to arm. + t.mock.timers.enable({ apis: ['setTimeout'] }); + setVisibility('hidden'); + t.mock.timers.tick(60_000); + t.mock.timers.reset(); + assert.ok(q('#master-password'), 'still on the unlock screen, no second lock attempted'); + + setVisibility('visible'); + q('[data-action="back"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + assert.ok(q('#drop-zone')); }); // ============================================================ @@ -1883,6 +2006,9 @@ test('an entry with Expires=True but no ExpiryTime, and no CreationTime either ( q('[data-action="cancel"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); q('[data-action="back"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); q('[data-action="close"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + dq('#dlg-confirm-discard [data-action="confirm-discard"]').dispatchEvent( + new dom.window.Event('click', { bubbles: true }), + ); }); test('attachments can be added, shown, and survive save/reload on KDBX 3.1 files', async () => { @@ -1961,6 +2087,9 @@ test('a stale attachment Ref (pointing to no pool data) is shown but downloading q('[data-action="back"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); q('[data-action="close"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + dq('#dlg-confirm-discard [data-action="confirm-discard"]').dispatchEvent( + new dom.window.Event('click', { bubbles: true }), + ); }); test('entry history: edits are snapshotted, and a past version can be restored or deleted', async () => { @@ -2113,6 +2242,9 @@ test('entry list sorting: by title, username, or modified time, in either direct assert.ok(titles()[0]?.includes('Bob')); q('[data-action="close"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + dq('#dlg-confirm-discard [data-action="confirm-discard"]').dispatchEvent( + new dom.window.Event('click', { bubbles: true }), + ); }); test('entry list table view: columns, masked password, click to copy, button to open', async (t) => { @@ -2264,6 +2396,9 @@ test('entry list table view: columns, masked password, click to copy, button to assert.equal(root().querySelectorAll('.entry-table').length, 0); q('[data-action="close"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + dq('#dlg-confirm-discard [data-action="confirm-discard"]').dispatchEvent( + new dom.window.Event('click', { bubbles: true }), + ); }); test('entry list: the sidebar drawer and panel overflow menu (mobile layout) open and close', async () => { @@ -2335,6 +2470,9 @@ test('entry list: the sidebar drawer and panel overflow menu (mobile layout) ope assert.equal(panelMenu.classList.contains('panel-menu-open'), false); q('[data-action="close"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + dq('#dlg-confirm-discard [data-action="confirm-discard"]').dispatchEvent( + new dom.window.Event('click', { bubbles: true }), + ); }); test('exporting entries as CSV or XML downloads an unencrypted plaintext file', async () => { @@ -2406,6 +2544,9 @@ test('exporting entries as CSV or XML downloads an unencrypted plaintext file', assert.equal(created.length, 2, 'closing must not trigger another export'); q('[data-action="close"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + dq('#dlg-confirm-discard [data-action="confirm-discard"]').dispatchEvent( + new dom.window.Event('click', { bubbles: true }), + ); }); test('exporting falls back to "entries" as the base filename when none is set', async () => { @@ -2450,6 +2591,9 @@ test('exporting falls back to "entries" as the base filename when none is set', assert.deepEqual(downloadNames, ['entries.csv']); q('[data-action="close"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + dq('#dlg-confirm-discard [data-action="confirm-discard"]').dispatchEvent( + new dom.window.Event('click', { bubbles: true }), + ); }); test('must() throws when a screen template is missing an element it depends on', async () => { @@ -2475,9 +2619,9 @@ test('must() throws when a screen template is missing an element it depends on', // call directly). Per spec, DOM event listener exceptions are reported to // the console, not rethrown to dispatchEvent's caller — jsdom's virtual // console surfaces them as a 'jsdomError' event instead. Close (not lock) - // is used here specifically because it stays synchronous when nothing is - // dirty — lock always awaits Kdbx#save() first, which would make the - // throw happen after this test's synchronous assertion already ran. + // is used here specifically because confirming it renders the next screen + // synchronously — lock always awaits Kdbx#save() first, which would make + // the throw happen after this test's synchronous assertion already ran. const closeBtn = q('[data-action="close"]'); root().remove(); @@ -2485,7 +2629,11 @@ test('must() throws when a screen template is missing an element it depends on', dom.virtualConsole.on('jsdomError', (err: Error) => { captured = err; }); + // Closing a saved database asks first, so the render happens on confirming. closeBtn.dispatchEvent(new dom.window.Event('click', { bubbles: true })); + dq('#dlg-confirm-discard [data-action="confirm-discard"]').dispatchEvent( + new dom.window.Event('click', { bubbles: true }), + ); assert.ok(captured, 'removing #root should make the next screen render throw'); assert.match(String(captured?.message ?? captured), /expected element not found/); diff --git a/pages/tests/cloud-google-drive-page.test.ts b/pages/tests/cloud-google-drive-page.test.ts index 0b4f652..89346b9 100644 --- a/pages/tests/cloud-google-drive-page.test.ts +++ b/pages/tests/cloud-google-drive-page.test.ts @@ -389,6 +389,18 @@ test('Google Drive connector', async (t) => { }); }); + await t.test('kw-title names the open database in the tab', () => { + sendMessage({ type: 'kw-title', filename: 'vault.kdbx', locked: true }, { source: frameWin }); + assert.equal(doc.title, '🔒 vault.kdbx - KeePass Web - Google Drive'); + + sendMessage({ type: 'kw-title', filename: 'vault.kdbx', locked: false }, { source: frameWin }); + assert.equal(doc.title, '🔓 vault.kdbx - KeePass Web - Google Drive'); + + // An app with nothing open reports no filename, leaving this page's own title. + sendMessage({ type: 'kw-title', filename: '', locked: true }, { source: frameWin }); + assert.equal(doc.title, 'KeePass Web - Google Drive'); + }); + await t.test('a stray kw-close-ack with nothing pending is a harmless no-op', () => { const before = frameInbox.length; sendMessage({ type: 'kw-close-ack' }, { source: frameWin }); @@ -397,6 +409,7 @@ test('Google Drive connector', async (t) => { }); await t.test('back to Drive asks the app first, and only leaves once it acks', () => { + sendMessage({ type: 'kw-title', filename: 'vault.kdbx', locked: false }, { source: frameWin }); click(q('[data-action="back-to-drive"]')); assert.ok(q('#app-frame'), 'still on the host screen — waiting for the app to confirm'); const req = frameInbox.at(-1)?.message; @@ -404,6 +417,7 @@ test('Google Drive connector', async (t) => { sendMessage({ type: 'kw-close-ack' }, { source: frameWin }); assert.ok(q('[data-action="pick"]'), 'now back at the chooser'); + assert.equal(doc.title, 'KeePass Web - Google Drive', 'the tab is this page again'); }); await t.test('a frame message after back to Drive completed is ignored', () => { diff --git a/pages/tests/local-page.test.ts b/pages/tests/local-page.test.ts index 6988670..8eb3c57 100644 --- a/pages/tests/local-page.test.ts +++ b/pages/tests/local-page.test.ts @@ -215,6 +215,18 @@ test('local file connector', async (t) => { assert.deepEqual(frameInbox.at(-1)?.message, { type: 'kw-saved', ok: true }); }); + await t.test('kw-title names the open database in the tab', () => { + sendMessage({ type: 'kw-title', filename: 'vault.kdbx', locked: true }, { source: frameWin }); + assert.equal(doc.title, '🔒 vault.kdbx - KeePass Web - Local file'); + + sendMessage({ type: 'kw-title', filename: 'vault.kdbx', locked: false }, { source: frameWin }); + assert.equal(doc.title, '🔓 vault.kdbx - KeePass Web - Local file'); + + // An app with nothing open reports no filename, leaving this page's own title. + sendMessage({ type: 'kw-title', filename: '', locked: true }, { source: frameWin }); + assert.equal(doc.title, 'KeePass Web - Local file'); + }); + await t.test('a stray kw-close-ack with nothing pending is a harmless no-op', () => { const before = frameInbox.length; sendMessage({ type: 'kw-close-ack' }, { source: frameWin }); @@ -223,6 +235,7 @@ test('local file connector', async (t) => { }); await t.test('back to chooser asks the app first, and only leaves once it acks', () => { + sendMessage({ type: 'kw-title', filename: 'vault.kdbx', locked: false }, { source: frameWin }); click(q('[data-action="back-to-chooser"]')); assert.ok(q('#app-frame'), 'still on the host screen — waiting for the app to confirm'); const req = frameInbox.at(-1)?.message; @@ -230,6 +243,7 @@ test('local file connector', async (t) => { sendMessage({ type: 'kw-close-ack' }, { source: frameWin }); assert.ok(q('#drop-zone'), 'now back at the chooser'); + assert.equal(doc.title, 'KeePass Web - Local file', 'the tab is this page again'); }); await t.test('a frame message after back to chooser completed is ignored', () => {