diff --git a/.changeset/announce-auth-errors.md b/.changeset/announce-auth-errors.md new file mode 100644 index 00000000..b499d6c2 --- /dev/null +++ b/.changeset/announce-auth-errors.md @@ -0,0 +1,9 @@ +--- +'ePDS': patch +--- + +Sign-in, recovery and handle-choice feedback is now announced automatically by screen readers. + +**Affects:** End users + +**End users:** authentication errors now use standard alert and live-region semantics across sign-in, recovery, handle selection, and the demo client, so screen-reader users hear failures without having to search the page for changed text. Repeated failures — such as entering a second incorrect one-time code — announce each time rather than falling silent, and handle availability ("Available!" / "Already taken.") is now announced as you type. diff --git a/packages/auth-service/src/routes/account-login.ts b/packages/auth-service/src/routes/account-login.ts index 7b044ab2..8d832555 100644 --- a/packages/auth-service/src/routes/account-login.ts +++ b/packages/auth-service/src/routes/account-login.ts @@ -151,7 +151,7 @@ function renderLoginForm(opts: { csrfToken: string; error?: string }): string {

Account Settings

Sign in to manage your account

- ${opts.error ? '

' + escapeHtml(opts.error) + '

' : ''} + ${opts.error ? '' : ''}
@@ -193,7 +193,7 @@ function renderOtpForm(opts: {

Enter your code

We sent a ${opts.otpLength}-${opts.otpCharset === 'alphanumeric' ? 'character' : 'digit'} code to ${escapeHtml(maskedEmail)}

- ${opts.error ? '

' + escapeHtml(opts.error) + '

' : ''} + ${opts.error ? '' : ''} diff --git a/packages/auth-service/src/routes/choose-handle.ts b/packages/auth-service/src/routes/choose-handle.ts index 555a912f..398fafe4 100644 --- a/packages/auth-service/src/routes/choose-handle.ts +++ b/packages/auth-service/src/routes/choose-handle.ts @@ -497,8 +497,12 @@ export function renderChooseHandlePage( customFaviconUrl?: string | null, customFaviconUrlDark?: string | null, ): string { + // role=alert only on the populated branch: it is static at render + // time, so the default assertive announcement is what we want. The + // empty placeholder is never written to by this page's script, so + // it needs no live-region semantics. const errorHtml = error - ? `
${escapeHtml(error)}
` + ? `` : `` return ` @@ -565,10 +569,15 @@ export function renderChooseHandlePage( spellcheck="false" minlength="5" maxlength="20" + aria-describedby="handle-status" > .${escapeHtml(handleDomain)}
-
+ +
${showRandomButton ? `` : ''} diff --git a/packages/auth-service/src/routes/login-page.ts b/packages/auth-service/src/routes/login-page.ts index 376b6343..34f8691a 100644 --- a/packages/auth-service/src/routes/login-page.ts +++ b/packages/auth-service/src/routes/login-page.ts @@ -654,7 +654,7 @@ export function renderLoginPage(opts: { ${logoHtml}

${opts.initialStep === 'otp' ? 'Enter your code' : 'Sign in'}

- + ${socialButtonsHtml} @@ -845,22 +845,26 @@ export function renderLoginPage(opts: { // Render the notice in the existing error banner so the // styling / position is consistent with other errors. The // copy is set via textContent (no HTML), the Start over - // button is built imperatively. + // button is built imperatively. Both go in through setFlash + // so the notice and its button land as one mutation and + // announce as a single message. clearError(); - showFlash( - 'Your sign-in has timed out. The code we sent will no longer work. Start sign-in again from the app you came from.', - 'error', - ); - errorEl.appendChild(document.createElement('br')); - var startOverBtn = document.createElement('button'); - startOverBtn.type = 'button'; - startOverBtn.id = 'btn-start-over'; - startOverBtn.className = 'flash-action'; - startOverBtn.textContent = 'Start over'; - startOverBtn.addEventListener('click', function() { - window.location.href = '/auth/abort'; + setFlash('error', function(frag) { + appendMessage( + frag, + 'Your sign-in has timed out. The code we sent will no longer work. Start sign-in again from the app you came from.', + ); + frag.appendChild(document.createElement('br')); + var startOverBtn = document.createElement('button'); + startOverBtn.type = 'button'; + startOverBtn.id = 'btn-start-over'; + startOverBtn.className = 'flash-action'; + startOverBtn.textContent = 'Start over'; + startOverBtn.addEventListener('click', function() { + window.location.href = '/auth/abort'; + }); + frag.appendChild(startOverBtn); }); - errorEl.appendChild(startOverBtn); } /** @@ -987,16 +991,52 @@ export function renderLoginPage(opts: { box.addEventListener('focus', function() { box.select(); }); }); - function showFlash(msg, kind) { - // Build the message DOM imperatively so showErrorWithAction - // can append an inline action (e.g. Resend) without ever - // interpolating user-influenced strings as HTML. textContent - // is the only sink for the msg argument, which neutralises - // any HTML in the better-auth error string. - errorEl.textContent = msg; + /** + * Swap the flash region's contents in a single mutation. + * + * Two ordering constraints make this fiddlier than it looks: + * + * 1. The region must already be visible before its text changes. + * A display:none element is excluded from the accessibility + * tree, so text written while hidden leaves the live region + * with no "before" state to diff against and assistive tech + * may never announce it. + * 2. The new content must arrive as one mutation. Building it + * off-DOM and appending a fragment means an error plus its + * inline action announce together rather than twice. + * + * Content is always built imperatively with textContent as the + * only sink for caller-supplied strings, so a reflected + * better-auth error can never be interpolated as HTML. + */ + function setFlash(kind, buildContent) { errorEl.classList.remove('error', 'success'); errorEl.classList.add(kind); errorEl.style.display = 'block'; + + var frag = document.createDocumentFragment(); + buildContent(frag); + + // Replace the children wholesale rather than assigning + // textContent in place. Re-submitting a bad OTP yields the + // identical string, which is not a DOM mutation and so would + // announce nothing — leaving the screen-reader user unsure the + // retry was even processed. Swapping nodes is always a + // mutation, so every failure announces. + errorEl.replaceChildren(frag); + } + + /** Append msg to frag as a text-only node. */ + function appendMessage(frag, msg) { + var msgNode = document.createElement('span'); + msgNode.textContent = msg; + frag.appendChild(msgNode); + } + + function showFlash(msg, kind) { + setFlash(kind, function(frag) { + appendMessage(frag, msg); + }); } function showError(msg) { showFlash(msg, 'error'); } @@ -1011,20 +1051,29 @@ export function renderLoginPage(opts: { * absent, behaves like showError. */ function showErrorWithAction(msg, actionLabel, onClick) { - showFlash(msg, 'error'); - if (!actionLabel || typeof onClick !== 'function') return; - errorEl.appendChild(document.createTextNode(' ')); - var btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'flash-action'; - btn.textContent = actionLabel; - btn.addEventListener('click', onClick); - errorEl.appendChild(btn); + if (!actionLabel || typeof onClick !== 'function') { + showError(msg); + return; + } + setFlash('error', function(frag) { + appendMessage(frag, msg); + frag.appendChild(document.createTextNode(' ')); + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'flash-action'; + btn.textContent = actionLabel; + btn.addEventListener('click', onClick); + frag.appendChild(btn); + }); } function clearError() { + // Empty the region before hiding it. Clearing after the + // display:none would mutate a region that is already out of + // the accessibility tree, which some assistive tech reports + // as a stale announcement. + errorEl.replaceChildren(); errorEl.style.display = 'none'; - errorEl.textContent = ''; errorEl.classList.remove('error', 'success'); } diff --git a/packages/auth-service/src/routes/recovery.ts b/packages/auth-service/src/routes/recovery.ts index ac6ae5df..3e6ee6da 100644 --- a/packages/auth-service/src/routes/recovery.ts +++ b/packages/auth-service/src/routes/recovery.ts @@ -340,7 +340,7 @@ export function renderRecoveryForm(opts: {

Account Recovery

Enter the backup email address associated with your account.

- ${opts.error ? '

' + escapeHtml(opts.error) + '

' : ''} + ${opts.error ? '' : ''} @@ -434,7 +434,7 @@ export function renderRecoveryOtpForm(opts: {

Enter recovery code

If a backup email matches, we sent a ${opts.otpLength}-${opts.otpCharset === 'alphanumeric' ? 'character' : 'digit'} code to ${escapeHtml(maskedEmail)}

- ${opts.error ? '

' + escapeHtml(opts.error) + '

' : ''} + ${opts.error ? '' : ''} diff --git a/packages/demo/src/app/components/LoginForm.tsx b/packages/demo/src/app/components/LoginForm.tsx index e13c1c67..94ffdf09 100644 --- a/packages/demo/src/app/components/LoginForm.tsx +++ b/packages/demo/src/app/components/LoginForm.tsx @@ -40,6 +40,7 @@ export function LoginForm() { <> {errorMessage && (