Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# authn.io ChangeLog

## 7.6.1 - 2026-07-dd

### Fixed
- Stop clipping the first party "Allow Wallet" permission dialog. The
popup height was a fixed guess (230px, plus a 30px URL bar allowance
applied only when the user agent looked like Firefox), so the wallet
row was cut off mid-line whenever the browser's popup chrome or the
greeting's wrapping exceeded that guess. The dialog fills the popup and
scrolls internally, so this surfaced as clipped content rather than a
scrollable window. The allowance now applies to every browser (Chrome
and Safari also show a URL bar in popups, and user agent sniffing
cannot predict the real chrome height), and the popup measures its own
content once loaded and resizes to fit, replacing both guesses with a
measurement.

### Added
- Non-API change: Add unit tests for the popup self-sizing logic and a
geometric-invariant test suite for the "Allow Wallet" dialog.
- Non-API change: Let the dev-only wallet chooser test harness render an
origin manifest (name and icon), so it reproduces the taller greeting
the real dialog shows for a site with a web app manifest.

## 7.6.0 - 2026-07-23

### Changed
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"start": "node authn.localhost.js",
"lint": "eslint",
"test:e2e": "playwright test wallet-chooser",
"test:unit": "node --test \"test/unit/*.spec.js\"",
"gallery": "playwright test gallery && node test/e2e/build-gallery-index.js"
},
"repository": {
Expand Down
136 changes: 136 additions & 0 deletions test/e2e/allow-wallet.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*!
* New BSD License (3-clause)
* Copyright (c) 2026, Digital Bazaar, Inc.
*/
import {expect, test} from '@playwright/test';

/* Geometric-invariant tests for the first party "Allow Wallet" permission
dialog, exercised via the dev-only `/test/wallet-chooser` harness route with
`type=permissionRequest`. The bug these guard: the popup height was a fixed
guess (230px + a Firefox-only 30px URL-bar allowance), so on any browser whose
popup chrome or content wrapping exceeded that guess, the wallet row and the
Block/Allow footer were clipped off the bottom of the window.

These assert the dialog fits — and that its interactive footer is reachable —
at the popup viewport heights the mediator actually requests, rather than
diffing pixels. */

/* Popup viewport heights to exercise. `DEFAULT_ALLOW_WALLET_POPUP_HEIGHT` is
290 (260 + a 30px chrome allowance), so the viewport the mediator actually
gets is 290 minus whatever chrome the browser adds. These bracket that range
down to the point where the content genuinely cannot fit and the popup relies
on `fitWindowToContent` growing it instead.

Playwright pins the viewport, so `resizeBy` is inert here by design: these
tests assert the CSS/content invariant at a given height. The resize logic
itself is unit-tested via `getWindowHeightAdjustment` (see
`test/unit/helpers.spec.js`). */
const HEIGHTS = [
{name: 'requested height', height: 290},
{name: 'chrome-reduced height', height: 260}
];

// A long origin/name exercises the text-wrapping cause of the clipping,
// independent of the popup-chrome cause.
const LONG_ORIGIN =
'https://wallet-with-a-very-long-hostname.example-organization.test';

async function gotoAllowWallet(page, {origin} = {}) {
const query = new URLSearchParams({
hints: '1',
type: 'permissionRequest',
qr: '0'
});
if(origin) {
query.set('origin', origin);
}
await page.goto(`/test/wallet-chooser?${query}`);
await page.locator('.wrm-modal-1p .wrm-modal-content').waitFor();
}

for(const {name, height} of HEIGHTS) {
test.describe(`allow-wallet dialog @ ${name} (${height}px)`, () => {
test.use({viewport: {width: 500, height}});

test.beforeEach(async ({page}) => gotoAllowWallet(page));

test('shows the Block and Allow buttons', async ({page}) => {
await expect(page.getByRole('button', {name: 'Block'})).toBeVisible();
await expect(page.getByRole('button', {name: 'Allow'})).toBeVisible();
});

// The footer is pinned outside the scrolling body, so it stays in the
// viewport at every height; this guards that pinning (a regression that
// let it scroll away would be caught here), not the clipping bug.
test('keeps the Block/Allow footer within the viewport',
async ({page}) => {
for(const label of ['Block', 'Allow']) {
const box = await page.getByRole('button', {name: label})
.boundingBox();
expect(box, `${label} button has no box`).not.toBeNull();
expect(box.y + box.height,
`${label} button bottom (${box.y + box.height}) exceeds ` +
`viewport height (${height})`).toBeLessThanOrEqual(height + 1);
}
});

/* The real clipping condition. The 1p panel is sized to exactly fill the
popup and its body region scrolls internally, so the *document* never
overflows and the pinned footer is always inside the viewport — neither a
document-level scroll check nor a footer-position check can detect this
bug. What the user sees instead is the greeting's wallet row cut off
mid-line inside that scrolling body. Assert the body does not need to
scroll: the popup must be tall enough for the greeting to fit whole. */
test('does not clip the greeting inside the scrolling body',
async ({page}) => {
const overflow = await page.evaluate(() => {
const body =
document.querySelector('.wrm-modal-content-header + div');
return body ? body.scrollHeight - body.clientHeight : null;
});
expect(overflow, 'dialog body region not found').not.toBeNull();
expect(overflow,
`dialog body overflows its box by ${overflow}px, clipping the ` +
'wallet row').toBeLessThanOrEqual(1);
});

// the greeting's origin card must be fully within the scrolling body's
// visible box, not cut off at its bottom edge
test('shows the whole origin card', async ({page}) => {
const cut = await page.evaluate(() => {
const body = document.querySelector('.wrm-modal-content-header + div');
const card = [...document.querySelectorAll('.wrm-flex-row')]
.find(el => !el.classList.contains('wrm-modal-content-header'));
if(!body || !card) {
return null;
}
return Math.round(card.getBoundingClientRect().bottom -
body.getBoundingClientRect().bottom);
});
expect(cut, 'origin card or body not found').not.toBeNull();
expect(cut, `origin card is cut off by ${cut}px`)
.toBeLessThanOrEqual(1);
});

test('keeps the origin row on one line', async ({page}) => {
// the origin/name must ellipsize rather than wrap; wrapping is what
// pushed the footer out of the popup
const wraps = await page.evaluate(() => {
const el = document.querySelector('.wrm-ellipsis');
if(!el) {
return 'missing';
}
return el.scrollHeight > el.clientHeight + 1;
});
expect(wraps).toBe(false);
});

test('with a long origin, still fits', async ({page}) => {
await gotoAllowWallet(page, {origin: LONG_ORIGIN});
const box = await page.getByRole('button', {name: 'Allow'})
.boundingBox();
expect(box).not.toBeNull();
expect(box.y + box.height).toBeLessThanOrEqual(height + 1);
});
});
}
130 changes: 130 additions & 0 deletions test/unit/helpers.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*!
* New BSD License (3-clause)
* Copyright (c) 2026, Digital Bazaar, Inc.
*/
import assert from 'node:assert/strict';
import {getWindowHeightAdjustment} from '../../web/mediator/helpers.js';
import {test} from 'node:test';

/* Unit tests for the pure core of the popup self-sizing fix. The mediator
opens the allow-wallet popup at a fixed height, but the browser subtracts its
own chrome by an unpredictable amount and the greeting reflows with the
wallet's name and origin, so the popup measures itself once loaded and
corrects. `getWindowHeightAdjustment` is that decision, isolated from the DOM:
given what the content needs and what the window has, how much should the
window grow or shrink?

Run with: node --test test/unit/ */

// a typical popup: 290px requested, ~60px of browser chrome
const TYPICAL = {
viewportHeight: 230,
outerHeight: 290,
availableHeight: 1000
};

test('grows the window when content overflows', () => {
// the shipped bug: 263px of content in a 230px viewport
const adjustment = getWindowHeightAdjustment({
...TYPICAL, contentHeight: 263
});
assert.equal(adjustment, 33);
});

test('shrinks the window when it is taller than the content', () => {
const adjustment = getWindowHeightAdjustment({
...TYPICAL, contentHeight: 180
});
assert.equal(adjustment, -50);
});

test('does nothing when the content already fits exactly', () => {
const adjustment = getWindowHeightAdjustment({
...TYPICAL, contentHeight: 230
});
assert.equal(adjustment, 0);
});

test('ignores sub-pixel differences', () => {
// fractional layout metrics must not trigger a pointless resize
for(const contentHeight of [230.4, 229.6, 231, 229]) {
assert.equal(getWindowHeightAdjustment({...TYPICAL, contentHeight}), 0,
`contentHeight ${contentHeight} should not resize`);
}
});

test('never grows the viewport beyond the available screen height', () => {
/* 60px of chrome on a 700px screen leaves a 640px viewport, so a window
with a 230px viewport can grow by at most 410 -- but the per-call cap of
400 binds first. */
const adjustment = getWindowHeightAdjustment({
viewportHeight: 230,
outerHeight: 290,
availableHeight: 700,
contentHeight: 5000
});
assert.equal(adjustment, 400);
});

test('respects the screen limit when it is tighter than the cap', () => {
// 40px chrome on a 400px screen => max viewport 360, so at most +130
const adjustment = getWindowHeightAdjustment({
viewportHeight: 230,
outerHeight: 270,
availableHeight: 400,
contentHeight: 5000
});
assert.equal(adjustment, 130);
});

test('never shrinks below the minimum usable height', () => {
// content claims almost nothing; the window must stay >= 160px
const adjustment = getWindowHeightAdjustment({
...TYPICAL, contentHeight: 10
});
assert.equal(adjustment, -70);
assert.equal(TYPICAL.viewportHeight + adjustment, 160);
});

test('bounds a single correction to the maximum adjustment', () => {
const adjustment = getWindowHeightAdjustment({
viewportHeight: 800,
outerHeight: 860,
availableHeight: 4000,
contentHeight: 8000
});
assert.equal(adjustment, 400);
});

test('returns 0 when measurements are missing or unusable', () => {
const cases = [
undefined,
{},
{contentHeight: 300, viewportHeight: 0, outerHeight: 290},
{contentHeight: 0, viewportHeight: 230, outerHeight: 290},
{contentHeight: 300, viewportHeight: 230, outerHeight: 0}
];
for(const input of cases) {
assert.equal(getWindowHeightAdjustment(input), 0,
`expected 0 for ${JSON.stringify(input)}`);
}
});

test('tolerates a missing screen height', () => {
// `availableHeight` unknown: still grows, bounded by the per-call cap
const adjustment = getWindowHeightAdjustment({
contentHeight: 263, viewportHeight: 230, outerHeight: 290
});
assert.equal(adjustment, 33);
});

test('handles a window reporting no chrome', () => {
// `outerHeight === innerHeight` (some embedded/headless contexts)
const adjustment = getWindowHeightAdjustment({
contentHeight: 300,
viewportHeight: 230,
outerHeight: 230,
availableHeight: 1000
});
assert.equal(adjustment, 70);
});
42 changes: 41 additions & 1 deletion web/components/FirstPartyMediatorWizard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,39 @@
* New BSD License (3-clause)
* Copyright (c) 2017-2026, Digital Bazaar, Inc.
*/
import {computed, onMounted, ref, toRaw} from 'vue';
import {computed, nextTick, onMounted, ref, toRaw} from 'vue';
import {FirstPartyMediator} from '../mediator/FirstPartyMediator.js';
import {fitWindowToContent} from '../mediator/helpers.js';
import MediatorWizard from './MediatorWizard.vue';

// give up waiting on a slow wallet icon rather than delay the resize; a late
// image just means the popup is corrected without its height
const IMAGE_LOAD_TIMEOUT = 1000;

/* Resolves once every <img> in the document has settled (loaded or errored),
or the timeout elapses. An icon that has not loaded reports no height, so
measuring before it settles under-reports the content. */
async function _waitForImages() {
const images = [...document.querySelectorAll('img')]
.filter(img => !img.complete);
if(images.length === 0) {
return;
}
const settled = images.map(img => new Promise(resolve => {
img.addEventListener('load', resolve, {once: true});
img.addEventListener('error', resolve, {once: true});
}));
let timer;
const timeout = new Promise(resolve => {
timer = setTimeout(resolve, IMAGE_LOAD_TIMEOUT);
});
try {
await Promise.race([Promise.all(settled), timeout]);
} finally {
clearTimeout(timer);
}
}

export default {
name: 'FirstPartyMediatorWizard',
components: {MediatorWizard},
Expand Down Expand Up @@ -118,6 +147,17 @@ export default {
await mediator.credentialRequestOriginManifestPromise;
credentialRequestOrigin.value = mediator.credentialRequestOrigin;
loading.value = false;

/* Correct the popup height now that the real content is known.
The height requested when opening the popup is a guess: the
browser subtracts its own chrome by an unpredictable amount and
the greeting reflows with the wallet's name and origin. Measuring
here replaces both guesses. Waits for the DOM update and for the
wallet icon to load, since an image that has not loaded yet does
not contribute its height. */
await nextTick();
await _waitForImages();
fitWindowToContent();
}
});
} catch(e) {
Expand Down
6 changes: 3 additions & 3 deletions web/components/MediatorGreeting.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</div>
<div else>
<div style="font-size: 14px; padding-top: 10px">
The following website {{websiteDesire}}
This site {{websiteDesire}}
</div>
<WrmOriginCard
style="padding: 20px 0 10px 0"
Expand Down Expand Up @@ -57,8 +57,8 @@ export default {
if(requestType.value === 'credentialStore') {
return 'has credentials for you to store:';
}
return 'wants to appear as an option when you\'re asked to share or ' +
'store information on other websites:';
return 'wants to be a digital wallet option when other sites' +
' ask you to share or store information';
});
return {websiteDesire};
}
Expand Down
Loading