Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ccf4ece
fix(auth-service): hide Resend on OTP screen when sign-in cannot recover
aspiers May 7, 2026
a5cb519
fix(demo): cookie outlasts OTP form sit times + honest error when it …
aspiers May 7, 2026
988be35
fix(auth-service): "Use different email" clears the email field + foc…
aspiers May 7, 2026
073da54
fix(auth-service): don't flash "Invalid OTP" on an empty Verify click
aspiers May 7, 2026
4e23ee1
fix(auth-service): inline Send-a-new-code on "Too many attempts" lockout
aspiers May 7, 2026
f6a243c
fix(demo): surface PDS timeout error_description as session_expired
aspiers May 7, 2026
4a899b6
fix(auth-service): friendlier stale-recovery-link error message
aspiers May 7, 2026
f01f703
fix(auth-service): also inline Resend after the post-lockout "Invalid…
aspiers May 7, 2026
7a177e5
fix(auth-service): friendlier stale-authorize-link error message
aspiers May 7, 2026
7dbd5d7
fix(auth-service): recovery link carries the real request_uri, not a …
aspiers May 7, 2026
11ea1cc
test(auth-service): unit-test the new login-page UX guards
aspiers May 7, 2026
5eddf75
fix(auth-service): filter pasted OTP content to the configured charset
aspiers May 7, 2026
a3f594a
fix(auth-service): filter typed OTP keystrokes to the configured charset
aspiers May 7, 2026
4aab907
fix(auth-service): account-login surfaces honest lockout message
aspiers May 7, 2026
3b82b31
test(auth-service): unit-test account-login error-message picker
aspiers May 7, 2026
010f0c0
fix(demo): invalid_grant on token exchange surfaces as session_expired
aspiers May 7, 2026
f900a9a
refactor(auth-service): share OTP-verify error-message picker between…
aspiers May 7, 2026
e0c367b
fix(auth-service): clear OTP boxes on Resend so old typing does not l…
aspiers May 7, 2026
74459b8
feat(auth-service): hint to check spam folder on OTP form
aspiers May 7, 2026
452eec3
test(auth-service): dedupe OTP-verify-error tests via shared constant…
aspiers May 7, 2026
453d9a5
fix(auth-service): explicit autocomplete=email on every email input
aspiers May 7, 2026
35e76d2
fix(auth-service): make sign-in errors announce to screen readers
aspiers May 7, 2026
c1cd111
fix(demo): friendlier error banners, no developer-facing wording
aspiers May 7, 2026
619eedf
fix(auth-service): charset filter on account-login + recovery OTP inputs
aspiers May 7, 2026
d3e75a2
fix(auth-service): handle picker error says "not available", not "jus…
aspiers May 7, 2026
97e5acb
test(e2e): de-flake @demo-cookie-expiry by skipping the email round-trip
aspiers May 7, 2026
fe13498
fix(auth-service): SMS / email autofill distributes the OTP across boxes
aspiers May 7, 2026
83de694
fix(pds-core): handle picker live-check flags reserved handles as una…
aspiers May 7, 2026
50077c0
fix(auth-service): visible keyboard focus on Verify and Resend buttons
aspiers May 7, 2026
645a2fb
fix(auth-service): visible keyboard focus on remaining route buttons
aspiers May 7, 2026
1493801
chore(changeset): keyboard focus-visible on buttons
aspiers May 7, 2026
116f833
fix(demo): error banner announces to screen readers via role=alert
aspiers May 7, 2026
fea121e
refactor(pds-core): extract handleIsUnavailable for the check-handle …
aspiers May 7, 2026
5fc9837
fix(shared): renderError adds role=alert on the error <p>
aspiers May 7, 2026
3ed32b9
fix(shared): malformed client_id no longer leaks into displayed app name
aspiers May 7, 2026
40ad0e0
fix(auth-service): account settings shows success / error banners aft…
aspiers May 7, 2026
473f6a0
refactor(auth-service): extract flash-resolver from /account route ha…
aspiers May 7, 2026
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
9 changes: 9 additions & 0 deletions .changeset/account-login-honest-lockout-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Account-settings sign-in shows useful guidance when the code can no longer be used.

**Affects:** End users

**End users:** when signing in to manage your account, typing the wrong code enough times locked out the original code — but the page kept saying "Invalid or expired code. Please try again.", which implied more typing might help. The page now distinguishes that case and says "That code can no longer be used. Click 'Resend code' below to get a fresh one." pointing you at the Resend button right below the error.
9 changes: 9 additions & 0 deletions .changeset/account-settings-flash-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Account Settings now confirms every action with a visible banner.

**Affects:** End users

**End users:** when you added a backup email, removed one, changed your handle, revoked a session, or hit a validation error on any of those, the page silently bounced back to the same form with no indication that anything had changed (or what went wrong). The page now shows a green "Backup email added" / "Handle updated" / etc. banner on success, and a red "That handle is not available" / "We couldn't send the verification email" / etc. banner on error — so you know whether your action took effect.
9 changes: 9 additions & 0 deletions .changeset/charset-filter-on-server-rendered-otp-forms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Account-settings and account-recovery code inputs also strip out characters that aren't part of the code.

**Affects:** End users

**End users:** the same paste/keystroke filter that the main sign-in form already had now applies to the standalone Account Settings sign-in and to the account recovery flow. If you copy your code from somewhere that wraps it in punctuation, or accidentally type a letter into a digits-only code, the input drops the stray characters silently instead of letting them through and rejecting the code as invalid.
9 changes: 9 additions & 0 deletions .changeset/clear-otp-boxes-on-resend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Clicking "Resend code" now empties the digit boxes for the new code.

**Affects:** End users

**End users:** if you started typing your sign-in code, realised you'd made a mistake, and clicked **Resend code**, the boxes still held the digits you had typed. You had to delete them yourself before you could enter the fresh code. The boxes now reset on Resend so you can just type the new code straight away.
9 changes: 9 additions & 0 deletions .changeset/dont-flash-invalid-otp-on-empty-submit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Empty Verify clicks no longer flash "Invalid OTP".

**Affects:** End users

**End users:** clicking **Verify** before typing the code (or pressing Enter on an empty form) used to flash a red "Invalid OTP" error, which was both misleading — you didn't type an invalid code, you typed nothing — and counted against the per-account rate limit. The form now just moves the cursor into the first empty digit box and waits for you to type, without bothering anyone with a fake error.
9 changes: 9 additions & 0 deletions .changeset/fallback-app-name-on-malformed-client-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Sign-in pages no longer leak a malformed client_id as the displayed app name.

**Affects:** End users

**End users:** if you arrived at a sign-in page with a broken `client_id` parameter on the URL (e.g. from a misconfigured app or a tampered link), the page would display that raw broken value as the app's name, e.g. "Sign in to not-a-url" or "Sign in to my-local-app". The page now falls back to "an application" in that case, so a malformed value in the URL doesn't leak into the visible page text or browser tab title.
9 changes: 9 additions & 0 deletions .changeset/filter-paste-to-otp-charset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Pasted sign-in codes are no longer auto-submitted with stray punctuation or letters.

**Affects:** End users

**End users:** if you copied your sign-in code from somewhere that wrapped it in punctuation or extra characters (e.g. an email reading "Your code is 1234-5678"), the page used to drop the punctuation straight into the digit boxes and submit the result, which the server would then reject as an invalid code. The same was true if you typed a stray letter into a digits-only code by accident. The boxes now filter both pasted and typed input to the format the code is in (digits only, or letters and digits) — so a paste cleans itself up and a stray keystroke is silently ignored.
9 changes: 9 additions & 0 deletions .changeset/friendlier-demo-error-banners.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Demo client's error banners now use plain user-friendly language instead of developer wording.

**Affects:** End users

**End users:** several of the demo's sign-in error banners used technical wording aimed at developers ("PAR rejected the request — check server logs", "token exchange failed") that didn't tell you anything actionable. The wording is now plain English and points at the right next step ("Please try again in a moment", "Please sign in again").
9 changes: 9 additions & 0 deletions .changeset/friendlier-stale-authorize-link-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Visiting a stale sign-in link no longer leaks an internal OAuth field name.

**Affects:** End users

**End users:** if you arrived at the sign-in page from a stale link or a direct paste — i.e. without an active sign-in flow — the page used to say "Missing request_uri parameter", which tells you nothing useful and leaks the name of an internal OAuth field. The page now says "Sign-in has to be started from the app you are signing into. Please return to that app and try again." so you know what to do next.
9 changes: 9 additions & 0 deletions .changeset/friendlier-stale-recovery-link-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Visiting a stale account-recovery link no longer leaks an internal OAuth field name.

**Affects:** End users

**End users:** if you arrived at the account-recovery page from a stale link or a direct paste — i.e. without an active sign-in flow — the page used to say "Missing request_uri parameter", which tells you nothing useful and leaks the name of an internal OAuth field. The page now says "Account recovery has to be started from the sign-in page. Please sign in again from the app you came from." so you know what to do next.
9 changes: 9 additions & 0 deletions .changeset/handle-not-available-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Handle-picker error wording is now accurate when the handle is reserved.

**Affects:** End users

**End users:** if you tried to claim a handle that was on the reserved list (admin, www, …) the picker said "That handle was just taken — please choose another." That's misleading — it wasn't just taken, it's permanently unavailable. The wording is now "That handle is not available — please choose another." which doesn't imply you can wait it out.
9 changes: 9 additions & 0 deletions .changeset/handle-picker-flags-reserved-handles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Handle picker now flags reserved handles as unavailable upfront.

**Affects:** End users

**End users:** the handle picker has a live availability check that says ✓ Available or ✗ Already taken as you type. For reserved handles (admin, support, www, …) it used to show ✓ Available, then refuse the handle on submit with a "not available" error — wasting the time you spent on the form. The live check now flags reserved handles as unavailable up front so you can keep going without losing what you typed.
13 changes: 13 additions & 0 deletions .changeset/hide-resend-when-sign-in-cannot-recover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'ePDS': patch
---

Sign-in no longer offers "Resend code" when the new code wouldn't have worked anyway.

**Affects:** End users

**End users:** Previously, if you sat on the email-code step long enough that the underlying sign-in had silently timed out (most often: leaving the tab in the background while reading email on your phone, or coming back after an interruption), the page would still show **Resend code**. Clicking it sent you a fresh email, but the moment you typed the new code you'd see "Sign in failed" — the code was issued for a sign-in that could no longer complete, so it never had a chance.

The page now hides the Resend button as soon as it knows the sign-in can't be recovered, and shows **Start over** in its place. Clicking Start over takes you back to the app you came from to begin again, instead of letting you waste time on a code that couldn't work.

If you're actively using the page (the tab in the foreground), nothing changes: Resend stays available and works the same way it always has.
9 changes: 9 additions & 0 deletions .changeset/inline-resend-on-too-many-attempts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

After too many wrong codes, the sign-in page now offers a one-click "Send a new code" instead of looking like more typing might help.

**Affects:** End users

**End users:** if you typed the wrong code enough times to get a "Too many attempts" lockout, the page used to leave you fighting an error that more typing couldn't fix — the underlying code had been thrown away by then, so any further attempts came back as "Invalid OTP" against nothing. The page now surfaces a **Send a new code** action right next to the error so you can get a fresh code in one click instead of hunting for the resend button or wondering if you have a typo.
9 changes: 9 additions & 0 deletions .changeset/keyboard-focus-visible-on-buttons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Sign-in buttons now show a visible keyboard focus ring.

**Affects:** End users

**End users:** keyboard users tabbing through the sign-in pages couldn't tell where focus was — the buttons styled out the browser default focus ring without replacing it. Verify, Resend, Use different email, Recover, Send recovery code, Continue with email, Update handle, etc. all now show a clear outline when focused via the keyboard, while still hiding the ring on a mouse click.
9 changes: 9 additions & 0 deletions .changeset/recovery-honest-lockout-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Account-recovery sign-in shows useful guidance when the code can no longer be used.

**Affects:** End users

**End users:** when recovering your account via a backup email, typing the wrong code enough times locked out the original code — but the page kept saying "Invalid or expired code. Please try again.", which implied more typing might help. The page now distinguishes that case and says "That code can no longer be used. Click 'Resend code' below to get a fresh one." pointing you at the Resend button right below the error, matching the standalone Account Settings sign-in flow.
9 changes: 9 additions & 0 deletions .changeset/recovery-link-uses-real-request-uri.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

The "Recover with backup email" link no longer breaks the "Back to sign in" path.

**Affects:** End users

**End users:** clicking **Recover with backup email** on the sign-in page used to leave you on a recovery flow that couldn't return you to the original sign-in: hitting **Back to sign in** at the end landed on a "data you submitted is invalid" error page from the underlying OAuth machinery, because the link forwarded a placeholder URL instead of the real sign-in context. The link now carries the active sign-in's actual context, so the back path round-trips cleanly even if you decide not to use recovery after all.
9 changes: 9 additions & 0 deletions .changeset/sms-autofill-distributes-across-otp-boxes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

iOS and Android SMS / email autofill now fills the digit boxes correctly.

**Affects:** End users

**End users:** if your phone offered to autofill the sign-in code from a recent text message or email, the autofill used to put the whole code into the first box and discard everything but the last digit — leaving you to retype the rest. The boxes now distribute the autofilled code across them, the same way they do when you paste it manually.
9 changes: 9 additions & 0 deletions .changeset/spam-hint-on-otp-form.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Sign-in code page hints to check spam folder when the email doesn't arrive.

**Affects:** End users

**End users:** the page now shows a small "If you don't see the email, check your spam folder." note below the digit boxes. If you typo'd your email or the message landed in spam, you used to wait and wait and then get frustrated; the hint surfaces the most common reason early so you can either check spam or click "Use different email" sooner.
9 changes: 9 additions & 0 deletions .changeset/use-different-email-clears-form.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

"Use different email" on the sign-in code page now clears the email field.

**Affects:** End users

**End users:** clicking **Use different email** on the code-entry page used to take you back to the email form with your previous address still filled in — exactly the opposite of what the button suggests, and forcing you to clear the field manually before you could type a new address. The form now starts empty, with the cursor in the field, so you can just type the new address and continue.
69 changes: 69 additions & 0 deletions e2e/step-definitions/account-recovery.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,3 +329,72 @@ Then(
await assertNoEmailFor(this.backupEmail)
},
)

// ---------------------------------------------------------------------------
// Recovery-link round-trip
// ---------------------------------------------------------------------------

Then(
"the recovery link points at the active OAuth flow's request_uri",
async function (this: EpdsWorld) {
const page = getPage(this)
// Pull the request_uri the auth-service was driving for this
// page from the URL we landed on.
const flowUrl = new URL(page.url())
const expectedRequestUri = flowUrl.searchParams.get('request_uri')
if (!expectedRequestUri) {
throw new Error(
`Expected the current page URL to carry a request_uri so the test can compare it to the recovery link, but URL was: ${page.url()}`,
)
}
const recoveryHref = await page
.locator('#recovery-link')
.getAttribute('href')
if (!recoveryHref) {
throw new Error('Recovery link not found on page')
}
const recoveryUrl = new URL(recoveryHref, flowUrl.origin)
const linkRequestUri = recoveryUrl.searchParams.get('request_uri')
if (linkRequestUri !== expectedRequestUri) {
throw new Error(
`Recovery link request_uri mismatch:\n expected: ${expectedRequestUri}\n got: ${linkRequestUri}`,
)
}
},
)

// ---------------------------------------------------------------------------
// Stale-recovery-link UX
// ---------------------------------------------------------------------------

When(
'the user navigates directly to the recovery page without an active sign-in',
async function (this: EpdsWorld) {
const page = getPage(this)
await page.goto(`${testEnv.authUrl}/auth/recover`)
},
)

Then(
'the page explains that recovery has to start from the sign-in page',
async function (this: EpdsWorld) {
const page = getPage(this)
await expect(page.locator('body')).toContainText(
/recovery has to be started from the sign-in page/i,
{ timeout: 10_000 },
)
},
)

Then(
'the page does not mention the technical field name {string}',
async function (this: EpdsWorld, fieldName: string) {
const page = getPage(this)
const body = (await page.locator('body').textContent()) ?? ''
if (body.toLowerCase().includes(fieldName.toLowerCase())) {
throw new Error(
`Expected the page to not surface the technical field name "${fieldName}", but its body text contained it. Page body: ${body}`,
)
}
},
)
Comment on lines +389 to +400
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

textContent() includes <script>/<style> and hidden elements — prefer innerText().

page.locator('body').textContent() returns the raw DOM text of every descendant node, including inline scripts and hidden inputs. If request_uri appears anywhere in a <script> block or a hidden field on the rendered page, the assertion will throw a false positive even though users never see it.

The intent of this step is to check what a user can read, so innerText() is the right API.

🐛 Proposed fix
-    const body = (await page.locator('body').textContent()) ?? ''
+    const body = (await page.locator('body').innerText()) ?? ''
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Then(
'the page does not mention the technical field name {string}',
async function (this: EpdsWorld, fieldName: string) {
const page = getPage(this)
const body = (await page.locator('body').textContent()) ?? ''
if (body.toLowerCase().includes(fieldName.toLowerCase())) {
throw new Error(
`Expected the page to not surface the technical field name "${fieldName}", but its body text contained it. Page body: ${body}`,
)
}
},
)
Then(
'the page does not mention the technical field name {string}',
async function (this: EpdsWorld, fieldName: string) {
const page = getPage(this)
const body = (await page.locator('body').innerText()) ?? ''
if (body.toLowerCase().includes(fieldName.toLowerCase())) {
throw new Error(
`Expected the page to not surface the technical field name "${fieldName}", but its body text contained it. Page body: ${body}`,
)
}
},
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/step-definitions/account-recovery.steps.ts` around lines 356 - 367, The
step definition that checks for absence of a technical field name uses
page.locator('body').textContent(), which collects text from scripts and hidden
elements; replace textContent() with innerText() in the Then step (inside the
async function using getPage and the body variable) so the assertion checks only
visible/rendered text users can read, preserving the nullish fallback (?? '')
and the rest of the error message logic.

Loading
Loading