Skip to content
Draft
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
11 changes: 11 additions & 0 deletions .changeset/mobile-code-paste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'ePDS': minor
---

Entering a sign-in code now works better on phones and with assistive technology.

**Affects:** End users, Client app developers

**End users:** Long-press paste and one-time-code autofill now fill the whole code. Filled positions can be selected and replaced, status messages are announced to screen readers, and the code field respects reduced-motion settings.

**Client app developers:** Existing trusted-client visual styling on direct `input` selectors and their focus or placeholder states continues to style the sign-in code slots automatically. Common `input.otp-box`, `input[data-slot]`, `.otp-box:focus`, and `.otp-box::placeholder` forms are also supported. New branding should use `.otp-box.active` for the selected position and `.otp-character.placeholder` for empty positions; `.otp-input-overlay` is internal and protected from broad `input` rules. Browser automation that previously filled each `.otp-box` should fill the single `#code` input instead; malformed CSS, mechanical input declarations, and more complex DOM-coupled selectors remain unchanged.
5 changes: 5 additions & 0 deletions docs/design/testing-gaps.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ this area's main gap: `login-page.ts`, `consent.ts`, `recovery.ts`, `account-log
signCallback round-trip).
- Pure helper functions within routes (e.g., handle validation in
`choose-handle.ts`) could be extracted and tested.
- OTP branding compatibility is extracted into `otp-branding-compat.ts` and
unit-tested across broad input, focus, placeholder, `.otp-box`, and
`[data-slot]` selectors, including malformed-CSS fallback and rejection of
mechanical declarations. Client-branding e2e coverage checks computed styles
and hit testing; mobile autofill still requires physical-device coverage.

**Recommended strategy:**

Expand Down
2 changes: 2 additions & 0 deletions docs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,8 @@ so retinting the whole card is one declaration:
}
```

The sign-in code control exposes `.otp-box` for each visual position, `.otp-box.active` for the selected position, and `.otp-character.placeholder` for empty positions. To preserve common visual rules written for the old inputs, ePDS projects direct `input` selectors and their `:focus`, `:focus-visible`, and `::placeholder` states onto those elements. The same projections support `input.otp-box` and `input[data-slot]`, while `.otp-box:focus`, `.otp-box:focus-visible`, and `.otp-box::placeholder` remain compatible. Mechanical declarations such as `display` and `touch-action`, more complex DOM-coupled selectors, and malformed stylesheets are not projected. Treat `.otp-input-overlay` as an internal interaction element; ePDS protects it from broad `input` rules so client branding cannot cover or disable the visual slots. Browser automation should fill the single `#code` input rather than individual `.otp-box` elements.

The upstream consent + chooser pages served by pds-core ship with
default Certified-style styling out of the box; trusted-client
`branding.css` is layered on top of it via cascade order, so any rules
Expand Down
52 changes: 29 additions & 23 deletions e2e/step-definitions/auth.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,13 @@ async function buildIncorrectOtpCode(world: EpdsWorld): Promise<string> {
// When world.otpCode is not set (e.g. the OTP email step was skipped),
// we cannot read OTP_LENGTH / OTP_CHARSET from env directly because the
// test runner has no access to the deployed service's environment on
// Railway. Instead, infer the config from the segmented OTP boxes that
// the auth service renders. Length comes from the box count; charset is
// inferred from the per-box inputmode attribute (the old hidden-input
// path's `pattern` attribute is no longer rendered).
// Railway. Instead, infer the config from the real OTP input rendered by
// the auth service.
const page = getPage(world)
const boxes = page.locator('.otp-box')
const otpLength = (await boxes.count()) || testEnv.otpLength
const inputModeAttr = await boxes.first().getAttribute('inputmode')
const input = page.locator('#code')
const maxlengthAttr = await input.getAttribute('maxlength')
const otpLength = Number(maxlengthAttr) || testEnv.otpLength
const inputModeAttr = await input.getAttribute('inputmode')
const otpCharset: 'numeric' | 'alphanumeric' =
inputModeAttr === 'numeric' ? 'numeric' : testEnv.otpCharset

Expand Down Expand Up @@ -351,18 +350,16 @@ Then(
async function (this: EpdsWorld) {
const page = getPage(this)
const boxes = page.locator('.otp-box')
const input = page.locator('#code')
const count = await boxes.count()
if (count === 0) {
throw new Error('No .otp-box elements found — OTP form is not rendered')
}
// Every box must be both visible AND enabled. Asserting on .first()
// alone hid regressions where a partial form (e.g. a stale
// "verifying..." latch on later boxes) blocked further attempts even
// though the first box looked fine.
for (let i = 0; i < count; i++) {
await expect(boxes.nth(i)).toBeVisible()
await expect(boxes.nth(i)).toBeEnabled()
for (let index = 0; index < count; index++) {
await expect(boxes.nth(index)).toBeVisible()
}
await expect(input).toBeVisible()
await expect(input).toBeEnabled()
},
)

Expand Down Expand Up @@ -628,14 +625,15 @@ Then(
When(
'the user enters two digits from the old OTP',
async function (this: EpdsWorld) {
const otpBoxes = getPage(this).locator('.otp-box')
await otpBoxes.nth(0).fill('1')
await otpBoxes.nth(1).fill('2')
// Prove the digits actually landed, so the later empty-box assertion
// The .otp-box divs are presentational; the code lives in the single
// #code input behind them.
const input = getPage(this).locator('#code')
await input.focus()
await input.pressSequentially('12')
// Prove the digits actually landed, so the later empty-input assertion
// demonstrates that resend cleared them rather than that they were
// never entered.
await expect(otpBoxes.nth(0)).toHaveValue('1')
await expect(otpBoxes.nth(1)).toHaveValue('2')
await expect(input).toHaveValue('12')
},
)

Expand Down Expand Up @@ -685,12 +683,20 @@ Then(
if (!this.otpCode) {
throw new Error('No fresh OTP was captured from the mail trap')
}
const otpBoxes = getPage(this).locator('.otp-box')
const page = getPage(this)
const otpBoxes = page.locator('.otp-box')
const input = page.locator('#code')
await expect(otpBoxes).toHaveCount(this.otpCode.length)
// The code lives in the single input behind the slots, so emptiness is
// its value. Each slot still renders a placeholder character when empty,
// hence the `empty` class rather than empty text.
await expect(input).toHaveValue('')
for (let index = 0; index < this.otpCode.length; index += 1) {
await expect(otpBoxes.nth(index)).toHaveValue('')
await expect(otpBoxes.nth(index)).toHaveClass(/\bempty\b/)
}
await expect(otpBoxes.first()).toBeFocused()
// Focus lands on the input; the first slot reflects it via `active`.
await expect(input).toBeFocused()
await expect(otpBoxes.first()).toHaveClass(/\bactive\b/)
},
)

Expand Down
122 changes: 122 additions & 0 deletions e2e/step-definitions/client-branding.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ const INJECTED_CSS_SIGNATURE = 'body { background: #1a1208'
// retint it.
const DEFAULT_LOGIN_BG_RGB = 'rgb(232, 232, 232)' // #E8E8E8

// Stable computed signatures for the trusted demo's default amber theme.
// These are intentionally independent of other elements on the rendered page
// so a regression affecting both source inputs and projected slots still fails.
const TRUSTED_OTP_STYLE = {
slotColor: 'rgb(254, 243, 199)', // #fef3c7
slotBackground: 'rgb(26, 18, 8)', // #1a1208
slotBorderColor: 'rgb(74, 53, 32)', // #4a3520
placeholderColor: 'rgb(185, 139, 85)', // #b98b55
activeBorderColor: 'rgb(245, 158, 11)', // #f59e0b
} as const

async function waitForLoginPage(world: EpdsWorld): Promise<void> {
const page = getPage(world)
// Wait for auth-service-specific element — #step-otp only exists on
Expand Down Expand Up @@ -109,6 +120,117 @@ Then(
},
)

Then(
'legacy OTP selectors style the visual code slots',
async function (this: EpdsWorld) {
await waitForLoginPage(this)
const page = getPage(this)
const input = page.locator('#code')
await expect(input).toBeVisible()
await input.focus()
await page.waitForFunction((expectedBorder) => {
const slot = document.querySelector<HTMLElement>('.otp-box.active')
return slot
? getComputedStyle(slot).borderColor === expectedBorder
: false
}, TRUSTED_OTP_STYLE.activeBorderColor)

const result = await page.evaluate(() => {
const slot = document.querySelector<HTMLElement>('.otp-box.active')
const character = slot?.querySelector<HTMLElement>(
'.otp-character.placeholder',
)
const idleSlot = document.querySelector<HTMLElement>(
'.otp-box:not(.active)',
)
if (!slot || !character || !idleSlot) return null

const css = Array.from(document.querySelectorAll('style'))
.map((style) => style.textContent)
.join('\n')

return {
hasInputAlias: css.includes(':where(#otp-boxes)>div:where(.otp-box)'),
hasFocusAlias: css.includes(
':where(#otp-boxes)>div:where(.otp-box).active',
),
hasPlaceholderAlias: css.includes(
':where(#otp-boxes)>div:where(.otp-box)>span:where(.otp-character.placeholder)',
),
slotColor: getComputedStyle(slot).color,
slotBackground: getComputedStyle(idleSlot).backgroundColor,
slotBorderColor: getComputedStyle(idleSlot).borderColor,
placeholderColor: getComputedStyle(character).color,
activeBorderColor: getComputedStyle(slot).borderColor,
}
})

expect(result).not.toBeNull()
expect(result).toMatchObject({
hasInputAlias: true,
hasFocusAlias: true,
hasPlaceholderAlias: true,
...TRUSTED_OTP_STYLE,
})
},
)

Then(
'the real OTP input remains protected and hit-testable',
async function (this: EpdsWorld) {
await waitForLoginPage(this)
const page = getPage(this)
const input = page.locator('#code')
await expect(input).toBeVisible()
await expect(input).toBeEnabled()
await input.fill('12')

const result = await input.evaluate((element) => {
const otpInput = element as HTMLInputElement
const style = getComputedStyle(otpInput)
const slots = Array.from(
document.querySelectorAll<HTMLElement>('.otp-box'),
)

return {
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
pointerEvents: style.pointerEvents,
touchAction: style.touchAction,
userSelect: style.userSelect,
backgroundColor: style.backgroundColor,
borderWidth: style.borderWidth,
hitTestedAcrossSlots: slots.every((slot) => {
const rect = slot.getBoundingClientRect()
return (
document.elementFromPoint(
rect.left + rect.width / 2,
rect.top + rect.height / 2,
) === otpInput
)
}),
renderedCharacters: slots
.slice(0, 2)
.map((slot) => slot.textContent.trim()),
}
})

expect(result).toMatchObject({
display: 'block',
visibility: 'visible',
opacity: '1',
pointerEvents: 'auto',
touchAction: 'auto',
userSelect: 'text',
backgroundColor: 'rgba(0, 0, 0, 0)',
borderWidth: '0px',
hitTestedAcrossSlots: true,
renderedCharacters: ['1', '2'],
})
},
)

Then(
'the login page body uses the default background color',
async function (this: EpdsWorld) {
Expand Down
58 changes: 38 additions & 20 deletions e2e/step-definitions/otp-character-filtering.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,18 @@ Then(
// Segmented sign-in grid
//
// The grid is a different code path from the two single-input forms: its
// filtering lives in JS input/paste handlers rather than an oninput
// attribute, and the paste handler additionally spreads the cleaned
// characters across boxes. Neither behaviour is observable in the rendered
// HTML, so only a browser-level test can catch a regression there.
// filtering lives in a JS input/paste handler rather than an oninput
// attribute, and the paste handler additionally normalizes the whole value
// before it is rendered across the slots. Neither behaviour is observable in
// the rendered HTML, so only a browser-level test can catch a regression.
//
// The grid is one real `#code` input behind non-interactive `.otp-box` divs
// (needed for mobile long-press paste and one-time-code autofill), so these
// steps drive `#code` and assert on what each slot displays.
// ---------------------------------------------------------------------------

const SIGN_IN_OTP_INPUT = '#code'

When(
'the sign-in OTP preview uses the {string} character policy',
async function (this: EpdsWorld, charset: string) {
Expand All @@ -47,15 +53,20 @@ When(
`${testEnv.authUrl}/preview/login-otp?otp_charset=${encodeURIComponent(charset)}`,
)
// The handlers under test are attached by the page's inline script, so
// wait for the boxes rather than racing the script with the first fill.
await page.locator('.otp-box').first().waitFor({ state: 'visible' })
// wait for the input rather than racing the script with the first fill.
await page.locator(SIGN_IN_OTP_INPUT).waitFor({ state: 'visible' })
},
)

When(
'the user types {string} into the first sign-in OTP box',
async function (this: EpdsWorld, value: string) {
await getPage(this).locator('.otp-box').first().fill(value)
// pressSequentially, not fill: typing one character at a time is what a
// user does, and it drives the input handler once per character the way
// the old per-box grid did.
const input = getPage(this).locator(SIGN_IN_OTP_INPUT)
await input.focus()
await input.pressSequentially(value)
},
)

Expand All @@ -64,14 +75,13 @@ When(
async function (this: EpdsWorld, value: string) {
// Playwright cannot portably seed the system clipboard, and the handler
// reads only event.clipboardData, so synthesise the event directly. This
// still drives the real listener, including its filtering and spreading.
// still drives the real listener, including its normalization.
await getPage(this)
.locator('.otp-box')
.first()
.evaluate((box, pasted) => {
.locator(SIGN_IN_OTP_INPUT)
.evaluate((input, pasted) => {
const data = new DataTransfer()
data.setData('text/plain', pasted)
box.dispatchEvent(
input.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: data,
bubbles: true,
Expand All @@ -85,21 +95,29 @@ When(
Then(
'the sign-in OTP boxes spell {string}',
async function (this: EpdsWorld, expected: string) {
const boxes = getPage(this).locator('.otp-box')
const page = getPage(this)
const boxes = page.locator('.otp-box')
const count = await boxes.count()
if (count < expected.length) {
throw new Error(
`Expected at least ${expected.length} OTP boxes, found ${count}`,
)
}
// Assert on every box rather than just the filled prefix: a spreading
// bug that scattered characters into later boxes would otherwise pass.
const padded = expected.padEnd(count, ' ')
// The input holds the value the form actually submits, so pin it directly
// rather than inferring it from the slots alone.
await expect(page.locator(SIGN_IN_OTP_INPUT)).toHaveValue(expected)
// Then assert every slot renders it, including that the trailing slots
// stayed empty — a normalization bug that leaked extra characters into
// later slots would otherwise pass. An empty slot still shows a
// placeholder character, so `empty` is the class, not the text.
for (let index = 0; index < count; index += 1) {
const character = padded[index]
await expect(boxes.nth(index)).toHaveValue(
character === ' ' ? '' : character,
)
const box = boxes.nth(index)
if (index >= expected.length) {
await expect(box).toHaveClass(/\bempty\b/)
} else {
await expect(box).not.toHaveClass(/\bempty\b/)
await expect(box.locator('.otp-character')).toHaveText(expected[index])
}
}
},
)
4 changes: 2 additions & 2 deletions e2e/support/flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ export async function pickHandle(world: EpdsWorld): Promise<void> {
* 2. Fill #email with the provided email, submit
* 3. Wait for #step-otp.active (30 s)
* 4. Fetch OTP from Mailpit via waitForEmail + extractOtp
* 5. Fill the segmented OTP boxes via fillOtp (page auto-submits on
* the last digit; no explicit verify click needed)
* 5. Fill the OTP control via fillOtp (page auto-submits when the
* complete code is entered; no explicit verify click needed)
* 6. Wait for /auth/choose-handle, fill #handle-input with a generated
* local part, wait for the availability check to confirm "available",
* then click #submit-btn
Expand Down
Loading
Loading