Skip to content

Commit b3f4c7b

Browse files
authored
Merge pull request #80 from keepass-web/issue-72-78-release-ux
1.0.0 UX fixes (#72,#73,#76,#77,#78)
2 parents 50187a2 + 9947e7d commit b3f4c7b

26 files changed

Lines changed: 866 additions & 70 deletions

docs/PAGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ flowchart TD
3737

3838
"Create a new database" is the other way into the same iframe: with no file to sniff, the connector embeds `0x67.html` straight away and, once it announces readiness, tells it to start a fresh database instead of opening one (`kw-create`, the create-side counterpart to `kw-open` in `packages/embed-protocol`). Naming and creating the database — `Kdbx.create` and everything it depends on — happens exactly where opening one does, inside `0x67.html`; the connector never gains its own copy of that logic, it only decides which of the two messages to send.
3939

40+
A connector owns two things beyond the file itself, both because the browser gives them to whichever document owns the tab rather than to the one inside the iframe. The first is the tab: the app knows which database is open and whether it is locked, but only the connector can name the tab and mark it with that state, so the app reports and the connector applies — which is why that logic lives in `pages/shared/` rather than in either page. The second is the keyboard: a keystroke goes to whichever document has focus, so a find pressed while the visitor is on the connector's own chrome would search the connector's page instead of the database, and the connector forwards it to the app rather than let that happen.
41+
4042
On save, since there is nowhere to write back to, the local connector downloads the updated bytes the same way `0x67.html` would if opened standalone — the only piece of this connector that's genuinely local-specific. This applies equally to a freshly created database: its first save is just a download, named for whatever the create screen's own form was given.
4143

4244
## How the Google Drive connector works

e2e/auto-lock.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ test('a tab left hidden locks the embedded database on its own', async () => {
8181
await otherTab.close();
8282
assert.equal(
8383
await page.title(),
84-
`🔒 ${basename(fixture.path)} - KeePass Web - Local file`,
84+
`🔒 ${basename(fixture.path)} - Locked - KeePass Web - Local file`,
8585
'and the tab bar shows it locked, without being opened',
8686
);
8787
});

e2e/entry-copy.test.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
/** Real-browser coverage for the entry table's controls (issue #67). jsdom
2-
* dispatches events straight at an element; only a real browser hit-tests a
3-
* coordinate, so this is what proves the cells and the open control are
4-
* actually clickable where they render.
1+
/** Real-browser coverage for the entry table's controls (issues #67, #76,
2+
* #77). jsdom dispatches events straight at an element and has no layout
3+
* engine at all; only a real browser hit-tests a coordinate and gives a cell
4+
* or a column a width, so this is what proves the controls are clickable where
5+
* they render, that the copy control holds the cell's right edge, and that a
6+
* dragged column actually changes size.
57
*
68
* What gets copied is asserted in the jsdom tests instead: headless Chrome
79
* refuses `navigator.clipboard.writeText` outright ("Write permission denied"),
@@ -49,7 +51,7 @@ async function usernameCellCentre(): Promise<{ x: number; y: number }> {
4951
const box = await app.$$eval(
5052
'.entry-table tbody td',
5153
(cells, name) => {
52-
const cell = cells.find((c) => c.firstChild?.textContent === name);
54+
const cell = cells.find((c) => c.querySelector('.entry-cell-text')?.textContent === name);
5355
if (!cell) return null;
5456
const rect = cell.getBoundingClientRect();
5557
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
@@ -69,6 +71,62 @@ test('clicking a value never opens the entry', async () => {
6971
assert.equal(await app.$('#detail-title'), null, 'the card stayed shut');
7072
});
7173

74+
test('the copy control holds the right edge of its cell', async () => {
75+
const geometry = await app.$$eval(
76+
'.entry-table tbody td',
77+
(cells, name) => {
78+
const cell = cells.find((c) => c.querySelector('.entry-cell-text')?.textContent === name);
79+
const hint = cell?.querySelector('.copy-hint');
80+
const text = cell?.querySelector('.entry-cell-text');
81+
if (!cell || !hint || !text) return null;
82+
const inner = cell.querySelector('.entry-cell') as HTMLElement;
83+
return {
84+
gapToTheRightEdge: inner.getBoundingClientRect().right - hint.getBoundingClientRect().right,
85+
gapAfterTheText: hint.getBoundingClientRect().left - text.getBoundingClientRect().right,
86+
};
87+
},
88+
'octocat',
89+
);
90+
assert.ok(geometry, 'the username cell carries both a text box and a copy control');
91+
92+
assert.ok(
93+
geometry.gapToTheRightEdge < 1,
94+
`the control sits at the cell's right edge, ${geometry.gapToTheRightEdge}px short of it`,
95+
);
96+
// A short value leaves room, and the control does not follow the text into it.
97+
assert.ok(
98+
geometry.gapAfterTheText > 8,
99+
`it is pinned there rather than trailing the text, ${geometry.gapAfterTheText}px behind it`,
100+
);
101+
});
102+
103+
test('dragging a column header changes that column width', async () => {
104+
const handle = await app.$('th[data-column="username"] .col-resize');
105+
assert.ok(handle, 'every resizable column carries a handle');
106+
107+
const widthOf = (): Promise<number> =>
108+
app.$eval('th[data-column="username"]', (th) => th.getBoundingClientRect().width);
109+
const before = await widthOf();
110+
111+
const box = await handle.boundingBox();
112+
assert.ok(box, 'the handle is laid out');
113+
const y = box.y + box.height / 2;
114+
await page.mouse.move(box.x + box.width / 2, y);
115+
await page.mouse.down();
116+
await page.mouse.move(box.x + box.width / 2 + 90, y);
117+
await page.mouse.up();
118+
119+
const after = await widthOf();
120+
assert.ok(after > before + 60, `the column widened, from ${before}px to ${after}px`);
121+
assert.equal(
122+
await app.$eval('th[data-column="username"] .col-resize', (el) =>
123+
el.getAttribute('aria-valuenow'),
124+
),
125+
String(Math.round(after)),
126+
'and says so to anyone reading it out',
127+
);
128+
});
129+
72130
test('the row control is the way into the card', async () => {
73131
const openButton = await app.$('.entry-table-open button');
74132
assert.ok(openButton, 'every row carries one');

e2e/local-to-app-embed.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,58 @@ test('dropping a file on local.html embeds a working 0x67 app that unlocks the s
6969
await iframeFrame.click('#unlock-btn');
7070

7171
await iframeFrame.waitForSelector('.entry-table');
72-
const titleText = await iframeFrame.$eval('.entry-table-title', (el) => el.textContent);
72+
const titleText = await iframeFrame.$eval(
73+
'.entry-table-title .entry-cell-text',
74+
(el) => el.textContent,
75+
);
7376
assert.ok(
7477
titleText?.includes(fixture.entryTitle),
7578
`unlocked vault shows the fixture entry, got "${titleText}"`,
7679
);
7780
});
7881

82+
test('the find keystroke reaches the app even when focus is on the host page', async () => {
83+
const fixture = await writeKdbxFixture();
84+
85+
await page.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' });
86+
const fileInput = (await page.waitForSelector('#file-input')) as ElementHandle<HTMLInputElement>;
87+
assert.ok(fileInput, 'the file input exists');
88+
await fileInput.uploadFile(fixture.path);
89+
90+
const iframeElement = await page.waitForSelector('#app-frame');
91+
assert.ok(iframeElement, 'the app is embedded');
92+
const app = await iframeElement.contentFrame();
93+
assert.ok(app, 'the iframe has a content frame');
94+
95+
const passwordInput = await app.waitForSelector('#master-password');
96+
assert.ok(passwordInput, 'the app shows its unlock screen');
97+
await passwordInput.type(fixture.password);
98+
await app.click('#unlock-btn');
99+
await app.waitForSelector('.entry-table');
100+
101+
// Click the host's own chrome, so this document — not the iframe — is the
102+
// one holding focus and the one the keystroke will be delivered to. Without
103+
// the host forwarding it, the app would never see it at all.
104+
await page.click('#host-filename');
105+
await app.$eval('#search-input', (el) => (el as HTMLElement).blur());
106+
assert.notEqual(
107+
await app.evaluate(() => document.activeElement?.id),
108+
'search-input',
109+
'focus really is off the search field to begin with',
110+
);
111+
112+
await page.keyboard.down('Control');
113+
await page.keyboard.press('f');
114+
await page.keyboard.up('Control');
115+
116+
await app.waitForFunction(() => document.activeElement?.id === 'search-input');
117+
assert.equal(
118+
await app.evaluate(() => document.activeElement?.id),
119+
'search-input',
120+
'the find crossed into the app and landed in its database-wide search',
121+
);
122+
});
123+
79124
test('clicking "Create a new database" on local.html embeds the app straight on its create-database screen', async () => {
80125
await page.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' });
81126

e2e/reveal-focus.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/** The caret has to survive a click on a control that acts on the field it
2+
* sits beside (issue #72). jsdom never moves focus on a press at all, so the
3+
* pages suite can only assert that the press is cancelled; a real browser is
4+
* the only place the focus move it prevents actually happens. The unlock
5+
* screen's reveal toggle stands in for the entry-edit row's controls too —
6+
* all of them go through the same helper. */
7+
import assert from 'node:assert/strict';
8+
import { after, before, test } from 'node:test';
9+
import { fileURLToPath } from 'node:url';
10+
import puppeteer, { type Browser, type ElementHandle, type Frame, type Page } from 'puppeteer-core';
11+
import { resolveChromePath } from './support/chrome.ts';
12+
import { type DistServer, startDistServer } from './support/dist-server.ts';
13+
import { writeKdbxFixture } from './support/fixture.ts';
14+
import { resolveLaunchOptions } from './support/launch-options.ts';
15+
16+
const distDir = fileURLToPath(new URL('../dist', import.meta.url));
17+
18+
let server: DistServer;
19+
let browser: Browser;
20+
let page: Page;
21+
22+
before(async () => {
23+
server = await startDistServer(distDir);
24+
browser = await puppeteer.launch({
25+
executablePath: resolveChromePath(),
26+
...resolveLaunchOptions(),
27+
args: ['--no-sandbox'],
28+
});
29+
page = await browser.newPage();
30+
});
31+
32+
after(async () => {
33+
await browser.close();
34+
await server.close();
35+
});
36+
37+
test('revealing the master password leaves the caret in the field', async () => {
38+
const fixture = await writeKdbxFixture();
39+
40+
await page.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' });
41+
const fileInput = (await page.waitForSelector('#file-input')) as ElementHandle<HTMLInputElement>;
42+
assert.ok(fileInput, 'the chooser offers a file input');
43+
await fileInput.uploadFile(fixture.path);
44+
45+
const frameElement = await page.waitForSelector('#app-frame');
46+
assert.ok(frameElement, 'the app is embedded in an iframe');
47+
const app = (await frameElement.contentFrame()) as Frame;
48+
49+
// Stop at the unlock screen: this is about typing a password, not reading a database.
50+
const passwordInput = await app.waitForSelector('#master-password');
51+
assert.ok(passwordInput, 'the embedded app shows its unlock screen');
52+
await passwordInput.type('half-typed');
53+
54+
await app.click('[data-action="toggle-password"]');
55+
56+
const state = await app.evaluate(() => ({
57+
focused: document.activeElement?.id ?? '',
58+
type: (document.getElementById('master-password') as HTMLInputElement).type,
59+
value: (document.getElementById('master-password') as HTMLInputElement).value,
60+
}));
61+
62+
assert.equal(state.focused, 'master-password', 'the field kept focus, so typing carries on');
63+
assert.equal(state.type, 'text', 'and the toggle still revealed the password');
64+
assert.equal(state.value, 'half-typed', 'without disturbing what was already typed');
65+
});

e2e/tab-title.test.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
/** The tab title belongs to local.html, but only the embedded 0x67 app knows
2-
* which database is open and whether it is locked — so the title is right
3-
* only if a real cross-document postMessage is delivered and handled. The
4-
* jsdom suites test each page in its own isolated window and cannot show
5-
* that; this drives the built distributables in Chrome, where the two
6-
* documents really are separate. */
1+
/** The tab belongs to local.html, but only the embedded 0x67 app knows which
2+
* database is open and whether it is locked — so the tab's name and its icon
3+
* are right only if a real cross-document postMessage is delivered and
4+
* handled. The jsdom suites test each page in its own isolated window and
5+
* cannot show that; this drives the built distributables in Chrome, where the
6+
* two documents really are separate. */
77
import assert from 'node:assert/strict';
88
import { basename } from 'node:path';
99
import { after, before, test } from 'node:test';
@@ -51,12 +51,16 @@ async function waitForTitle(expected: string): Promise<void> {
5151
}
5252
}
5353

54+
const tabIcon = (): Promise<string> =>
55+
page.$eval('link[rel="icon"]', (link) => link.getAttribute('href') ?? '');
56+
5457
test('the tab names the open database and tracks its lock state', async () => {
5558
const fixture = await writeKdbxFixture();
5659
const filename = basename(fixture.path);
5760

5861
await page.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' });
5962
assert.equal(await page.title(), BASE_TITLE, 'nothing open, so the tab is just this page');
63+
const pageIcon = await tabIcon();
6064

6165
// waitForSelector can't infer the element type from an id selector.
6266
const fileInput = (await page.waitForSelector('#file-input')) as ElementHandle<HTMLInputElement>;
@@ -68,15 +72,19 @@ test('the tab names the open database and tracks its lock state', async () => {
6872
const iframeFrame = await iframeElement.contentFrame();
6973
assert.ok(iframeFrame, 'the iframe has a content frame');
7074

71-
await waitForTitle(`🔒 ${filename} - ${BASE_TITLE}`);
75+
await waitForTitle(`🔒 ${filename} - Locked - ${BASE_TITLE}`);
76+
const lockedIcon = await tabIcon();
77+
assert.notEqual(lockedIcon, pageIcon, 'a held database is not the page at rest');
7278

7379
const passwordInput = await iframeFrame.waitForSelector('#master-password');
7480
assert.ok(passwordInput, 'the embedded app went straight to its unlock screen');
7581
await passwordInput.type(fixture.password);
7682
await iframeFrame.click('#unlock-btn');
7783

7884
await iframeFrame.waitForSelector('.entry-table');
79-
await waitForTitle(`🔓 ${filename} - ${BASE_TITLE}`);
85+
await waitForTitle(`🔓 ${filename} - Unlocked - ${BASE_TITLE}`);
86+
const unlockedIcon = await tabIcon();
87+
assert.notEqual(unlockedIcon, lockedIcon, 'and the two states do not share an icon');
8088

8189
// Nothing is unsaved, but the app still asks before giving the database up.
8290
await page.click('[data-action="back-to-chooser"]');
@@ -86,4 +94,5 @@ test('the tab names the open database and tracks its lock state', async () => {
8694
await iframeFrame.click('#dlg-confirm-discard [data-action="confirm-discard"]');
8795
await page.waitForSelector('#drop-zone');
8896
await waitForTitle(BASE_TITLE);
97+
assert.equal(await tabIcon(), pageIcon, 'closing hands the page its own icon back');
8998
});

packages/embed-protocol/src/index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
keepass-web implementation and whatever host embeds it in an iframe.
33
Centralizes shapes/guards/builders (previously duplicated per side) so
44
both ends provably agree on the wire format: kw-ready, kw-open, kw-create,
5-
kw-save, kw-saved, kw-title, kw-close-request, kw-close-ack, kw-close. */
5+
kw-save, kw-saved, kw-title, kw-find, kw-close-request, kw-close-ack,
6+
kw-close. */
67

78
export interface ReadyMessage {
89
type: 'kw-ready';
@@ -38,6 +39,13 @@ export interface TitleMessage {
3839
locked: boolean;
3940
}
4041

42+
/* Whichever document has focus receives the keystroke, and outside the iframe
43+
that is the host; it forwards the find rather than letting the browser's own
44+
search the one page it can see (#78). */
45+
export interface FindMessage {
46+
type: 'kw-find';
47+
}
48+
4149
export interface CloseRequestMessage {
4250
type: 'kw-close-request';
4351
}
@@ -96,6 +104,10 @@ export function isTitleMessage(data: unknown): data is TitleMessage {
96104
return typeof rec.filename === 'string' && typeof rec.locked === 'boolean';
97105
}
98106

107+
export function isFindMessage(data: unknown): data is FindMessage {
108+
return hasType(data, 'kw-find');
109+
}
110+
99111
export function isCloseRequestMessage(data: unknown): data is CloseRequestMessage {
100112
return hasType(data, 'kw-close-request');
101113
}
@@ -134,6 +146,10 @@ export function titleMessage(filename: string, locked: boolean): TitleMessage {
134146
return { type: 'kw-title', filename, locked };
135147
}
136148

149+
export function findMessage(): FindMessage {
150+
return { type: 'kw-find' };
151+
}
152+
137153
export function closeRequestMessage(): CloseRequestMessage {
138154
return { type: 'kw-close-request' };
139155
}

packages/embed-protocol/tests/index.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import {
55
closeMessage,
66
closeRequestMessage,
77
createMessage,
8+
findMessage,
89
isCloseAckMessage,
910
isCloseMessage,
1011
isCloseRequestMessage,
1112
isCreateMessage,
13+
isFindMessage,
1214
isOpenMessage,
1315
isReadyMessage,
1416
isSavedMessage,
@@ -84,6 +86,14 @@ test('titleMessage / isTitleMessage round-trip', () => {
8486
assert.equal(isTitleMessage({ type: 'kw-title', filename: 42, locked: true }), false);
8587
});
8688

89+
test('findMessage / isFindMessage round-trip', () => {
90+
assert.deepEqual(findMessage(), { type: 'kw-find' });
91+
assert.equal(isFindMessage(findMessage()), true);
92+
assert.equal(isFindMessage(null), false);
93+
assert.equal(isFindMessage(42), false);
94+
assert.equal(isFindMessage({ type: 'nope' }), false);
95+
});
96+
8797
test('closeRequestMessage / isCloseRequestMessage round-trip', () => {
8898
assert.deepEqual(closeRequestMessage(), { type: 'kw-close-request' });
8999
assert.equal(isCloseRequestMessage(closeRequestMessage()), true);

0 commit comments

Comments
 (0)