Skip to content

Commit 7f4c992

Browse files
authored
Merge pull request #29 from keepass-web/issue-1-warn-before-unload
Avoid accidental data loss
2 parents 7049181 + ade5626 commit 7f4c992

9 files changed

Lines changed: 156 additions & 12 deletions

File tree

pages/0x67/page.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1434,11 +1434,13 @@ function openMoveToDialog(
14341434
// it a vault to open and receive the edited vault back, without the app
14351435
// reimplementing any of its own file handling.
14361436
//
1437-
// The protocol is four same-origin postMessage types:
1437+
// The protocol is six same-origin postMessage types:
14381438
// app → host : { type: 'kw-ready' } app booted, send a vault
14391439
// host → app : { type: 'kw-open', filename, bytes } open this vault (bytes: ArrayBuffer)
14401440
// app → host : { type: 'kw-save', filename, bytes } user saved; please persist (bytes: ArrayBuffer)
14411441
// host → app : { type: 'kw-saved', ok, error? } result of that persist
1442+
// host → app : { type: 'kw-close-request' } host wants to remove this iframe; may I?
1443+
// app → host : { type: 'kw-close-ack' } yes — nothing unsaved, or the user chose to discard
14421444
//
14431445
// Every inbound message is checked to come from the parent frame at this
14441446
// page's own origin; anything else is ignored. Nothing here runs unless the
@@ -1473,6 +1475,8 @@ function handleHostMessage(event: MessageEvent): void {
14731475
showUnlock();
14741476
} else if (data.type === 'kw-saved') {
14751477
notifyHostSaveResult(data.ok === true, typeof data.error === 'string' ? data.error : undefined);
1478+
} else if (data.type === 'kw-close-request') {
1479+
confirmDiscardIfDirty(() => postToHost({ type: 'kw-close-ack' }));
14761480
}
14771481
}
14781482

@@ -1514,4 +1518,13 @@ if (isEmbedded()) {
15141518
postToHost({ type: 'kw-ready' });
15151519
}
15161520

1521+
// Closing the tab, reloading, or navigating away with unsaved edits would
1522+
// otherwise discard them with no warning — there's no autosave to fall back
1523+
// on. This is the browser's own native prompt, not a custom dialog.
1524+
window.addEventListener('beforeunload', (e) => {
1525+
if (!app.dirty) return;
1526+
e.preventDefault();
1527+
e.returnValue = true;
1528+
});
1529+
15171530
showUpload();

pages/cloud-google-drive/bundle-iife.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"buildDriveDownloadUrl",
88
"buildDriveUpdateUrl",
99
"isReadyMessage",
10-
"isSaveMessage"
10+
"isSaveMessage",
11+
"isCloseAckMessage"
1112
]
1213
}

pages/cloud-google-drive/globals.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ declare function buildDriveDownloadUrl(apiBase: string, id: string): string;
2727
declare function buildDriveUpdateUrl(uploadBase: string, id: string): string;
2828
declare function isReadyMessage(data: unknown): boolean;
2929
declare function isSaveMessage(data: unknown): data is SaveMessage;
30+
declare function isCloseAckMessage(data: unknown): boolean;
3031

3132
// --- Google SDKs (loaded at runtime from Google) ---
3233
// Declared loosely on purpose — these are foreign, remotely-loaded APIs, not

pages/cloud-google-drive/logic.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,14 @@ export function isSaveMessage(data: unknown): data is SaveMessage {
7272
rec.type === 'kw-save' && typeof rec.filename === 'string' && rec.bytes instanceof ArrayBuffer
7373
);
7474
}
75+
76+
/** True if `data` is the embedded app's "safe to remove me now" reply to a
77+
* `kw-close-request` — either nothing was unsaved, or the user chose to
78+
* discard it. */
79+
export function isCloseAckMessage(data: unknown): boolean {
80+
return (
81+
data !== null &&
82+
typeof data === 'object' &&
83+
(data as Record<string, unknown>).type === 'kw-close-ack'
84+
);
85+
}

pages/cloud-google-drive/page.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ let currentFile: DriveFile | null = null;
7676
let pendingOpen: { filename: string; bytes: ArrayBuffer } | null = null;
7777
let pickerApiLoaded = false;
7878
let tokenClient: TokenClient | null = null;
79+
// Set while waiting for the embedded app to ack a kw-close-request, so
80+
// handleFrameMessage knows what to run once it's safe to tear the iframe down.
81+
let pendingClose: (() => void) | null = null;
7982
// Cached so the GIS script loads at most once, and concurrent callers share it.
8083
let gisReady: Promise<void> | null = null;
8184

@@ -264,17 +267,33 @@ function showHost(file: DriveFile, bytes: ArrayBuffer): void {
264267
setRoot(cloneTemplate('tpl-host'));
265268
qs('#host-filename').textContent = file.name;
266269
qs('[data-action="back-to-drive"]').addEventListener('click', () => {
267-
window.removeEventListener('message', handleFrameMessage);
268-
currentFile = null;
269-
pendingOpen = null;
270-
showChooser();
270+
requestCloseIframe(() => {
271+
window.removeEventListener('message', handleFrameMessage);
272+
currentFile = null;
273+
pendingOpen = null;
274+
showChooser();
275+
});
271276
});
272277
window.addEventListener('message', handleFrameMessage);
273278
// Setting src last means the iframe's script (and its kw-ready handshake)
274279
// can't fire before the listener above is attached.
275280
qs<HTMLIFrameElement>('#app-frame').src = '0x67.html';
276281
}
277282

283+
/** Ask the embedded app whether it's safe to remove the iframe — it may have
284+
* unsaved edits, in which case it shows its own discard-confirmation dialog
285+
* and only acks if the user agrees. `afterClose` runs once that ack arrives
286+
* (see handleFrameMessage's isCloseAckMessage branch); if the user cancels,
287+
* no ack ever comes and nothing happens, exactly like cancelling the same
288+
* dialog standalone. */
289+
function requestCloseIframe(afterClose: () => void): void {
290+
pendingClose = afterClose;
291+
must(qs<HTMLIFrameElement>('#app-frame').contentWindow).postMessage(
292+
{ type: 'kw-close-request' },
293+
APP_ORIGIN,
294+
);
295+
}
296+
278297
function handleFrameMessage(event: MessageEvent): void {
279298
if (event.origin !== APP_ORIGIN) return;
280299
const iframe = document.getElementById('app-frame') as HTMLIFrameElement | null;
@@ -286,6 +305,10 @@ function handleFrameMessage(event: MessageEvent): void {
286305
source.postMessage({ type: 'kw-open', filename: open.filename, bytes: open.bytes }, APP_ORIGIN);
287306
} else if (isSaveMessage(event.data)) {
288307
void saveToDrive(event.data.bytes, source);
308+
} else if (isCloseAckMessage(event.data)) {
309+
const afterClose = pendingClose;
310+
pendingClose = null;
311+
afterClose?.();
289312
}
290313
}
291314

pages/tests/0x67-host.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,4 +295,35 @@ test('0x67 embedded in a host frame', async (t) => {
295295
assert.equal(dq<HTMLElement>('[data-role="save-status"]').textContent, 'Save failed.');
296296
},
297297
);
298+
299+
await t.test('kw-close-request acks immediately when nothing is dirty', () => {
300+
// The last write-back above failed, but it was a retry of an edit already
301+
// saved successfully earlier in this walkthrough — nothing new since then.
302+
const before = hostInbox.length;
303+
sendFromHost({ type: 'kw-close-request' });
304+
assert.equal(hostInbox.length, before + 1);
305+
assert.deepEqual(lastHostMessage(), { type: 'kw-close-ack' });
306+
assert.equal(dq<HTMLDialogElement>('#dlg-confirm-discard').open, false);
307+
});
308+
309+
await t.test(
310+
'kw-close-request with unsaved changes opens the confirm dialog; confirming acks the host',
311+
() => {
312+
// The walkthrough above left us on the entry-detail screen (commitEdits
313+
// returns there); back to the list, where a fresh edit can be made.
314+
click(q('[data-action="back"]'));
315+
click(q('[data-action="add-entry"]'));
316+
317+
const before = hostInbox.length;
318+
sendFromHost({ type: 'kw-close-request' });
319+
assert.equal(hostInbox.length, before, 'no ack until the user decides');
320+
const dlg = dq<HTMLDialogElement>('#dlg-confirm-discard');
321+
assert.equal(dlg.open, true);
322+
323+
click(dq('#dlg-confirm-discard [data-action="confirm-discard"]'));
324+
assert.equal(dlg.open, false);
325+
assert.equal(hostInbox.length, before + 1);
326+
assert.deepEqual(lastHostMessage(), { type: 'kw-close-ack' });
327+
},
328+
);
298329
});

pages/tests/0x67-page.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1482,6 +1482,48 @@ test('closing with unsaved changes prompts to discard, and confirming discards t
14821482
assert.ok(q('#drop-zone'), 'confirming discard returns to the upload screen');
14831483
});
14841484

1485+
test('beforeunload is only blocked while there are unsaved edits', async () => {
1486+
const fileInput = q<HTMLInputElement>('#file-input');
1487+
setFiles(fileInput, [makeFile('beforeunload-test.kdbx', dbBytes)]);
1488+
dispatch(fileInput, 'change');
1489+
await waitFor(() => q('#master-password') !== null);
1490+
1491+
q<HTMLInputElement>('#master-password').value = PASSWORD;
1492+
const keyfileInput = q<HTMLInputElement>('#keyfile-input');
1493+
setFiles(keyfileInput, [makeFile('keyfile.bin', KEYFILE)]);
1494+
dispatch(keyfileInput, 'change');
1495+
await waitFor(() => q<HTMLElement>('#keyfile-label').textContent === 'keyfile.bin');
1496+
dispatch(q('#unlock-form'), 'submit');
1497+
await waitFor(() => dom.window.document.body.classList.contains('app-mode'));
1498+
1499+
const fireBeforeUnload = (): Event => dispatch(dom.window, 'beforeunload');
1500+
1501+
assert.equal(
1502+
fireBeforeUnload().defaultPrevented,
1503+
false,
1504+
'nothing unsaved yet, so the tab may close freely',
1505+
);
1506+
1507+
q('[data-action="add-entry"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
1508+
1509+
assert.equal(fireBeforeUnload().defaultPrevented, true, 'an unsaved edit blocks the unload');
1510+
1511+
// Return to the entry list (the close button lives in its header, not the
1512+
// entry-edit screen add-entry leaves us on — same recovery as the test
1513+
// above), then discard, leaving the app back on the upload screen as every
1514+
// other test here expects.
1515+
q('[data-action="save"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
1516+
dq('#dlg-save [data-action="close"]').dispatchEvent(
1517+
new dom.window.Event('click', { bubbles: true }),
1518+
);
1519+
q('[data-action="back"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
1520+
q('[data-action="close"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
1521+
dq('#dlg-confirm-discard [data-action="confirm-discard"]').dispatchEvent(
1522+
new dom.window.Event('click', { bubbles: true }),
1523+
);
1524+
assert.ok(q('#drop-zone'));
1525+
});
1526+
14851527
// ============================================================
14861528
// Edge cases: closing genuinely reachable defensive branches
14871529
// ============================================================

pages/tests/cloud-google-drive-logic.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { test } from 'node:test';
1111
import {
1212
buildDriveDownloadUrl,
1313
buildDriveUpdateUrl,
14+
isCloseAckMessage,
1415
isReadyMessage,
1516
isSaveMessage,
1617
must,
@@ -51,3 +52,10 @@ test('isSaveMessage requires a filename string and ArrayBuffer bytes', () => {
5152
assert.equal(isSaveMessage({ type: 'kw-save', filename: 1, bytes: new ArrayBuffer(2) }), false);
5253
assert.equal(isSaveMessage({ type: 'kw-save', filename: 'a', bytes: 'no' }), false);
5354
});
55+
56+
test('isCloseAckMessage recognises the close acknowledgement', () => {
57+
assert.equal(isCloseAckMessage({ type: 'kw-close-ack' }), true);
58+
assert.equal(isCloseAckMessage(null), false);
59+
assert.equal(isCloseAckMessage(42), false);
60+
assert.equal(isCloseAckMessage({ type: 'nope' }), false);
61+
});

pages/tests/cloud-google-drive-page.test.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -343,16 +343,30 @@ test('Google Drive connector', async (t) => {
343343
});
344344
});
345345

346-
await t.test('a frame message after the iframe is gone is ignored', () => {
347-
q<HTMLIFrameElement>('#app-frame').remove();
348-
sendMessage({ type: 'kw-ready' }, { source: frameWin });
349-
assert.equal(frameInbox.length, 4, 'nothing more posted');
346+
await t.test('a stray kw-close-ack with nothing pending is a harmless no-op', () => {
347+
const before = frameInbox.length;
348+
sendMessage({ type: 'kw-close-ack' }, { source: frameWin });
349+
assert.equal(frameInbox.length, before, 'nothing posted back');
350+
assert.ok(q('#app-frame'), 'still showing the host screen');
350351
});
351352

352-
await t.test('back to Drive returns to the chooser, and sign-out returns to sign-in', () => {
353+
await t.test('back to Drive asks the app first, and only leaves once it acks', () => {
353354
click(q('[data-action="back-to-drive"]'));
354-
assert.ok(q('[data-action="pick"]'));
355+
assert.ok(q('#app-frame'), 'still on the host screen — waiting for the app to confirm');
356+
const req = frameInbox.at(-1)?.message;
357+
assert.equal(req?.type, 'kw-close-request');
358+
359+
sendMessage({ type: 'kw-close-ack' }, { source: frameWin });
360+
assert.ok(q('[data-action="pick"]'), 'now back at the chooser');
361+
});
362+
363+
await t.test('a frame message after back to Drive completed is ignored', () => {
364+
const before = frameInbox.length;
365+
sendMessage({ type: 'kw-ready' }, { source: frameWin });
366+
assert.equal(frameInbox.length, before, 'nothing more posted — the iframe is gone');
367+
});
355368

369+
await t.test('sign-out returns to sign-in', () => {
356370
click(q('[data-action="signout"]'));
357371
assert.ok(q('[data-action="signin"]'));
358372
});

0 commit comments

Comments
 (0)