Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/announce-auth-errors.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/auth-service/src/routes/account-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ function renderLoginForm(opts: { csrfToken: string; error?: string }): string {
<div class="container">
<h1>Account Settings</h1>
<p class="subtitle">Sign in to manage your account</p>
${opts.error ? '<p class="error">' + escapeHtml(opts.error) + '</p>' : ''}
${opts.error ? '<p class="error" role="alert">' + escapeHtml(opts.error) + '</p>' : ''}
<form method="POST" action="/account/send-otp">
<input type="hidden" name="csrf" value="${escapeHtml(opts.csrfToken)}">
<div class="field">
Expand Down Expand Up @@ -193,7 +193,7 @@ function renderOtpForm(opts: {
<div class="container">
<h1>Enter your code</h1>
<p id="otp-help" class="subtitle">We sent a ${opts.otpLength}-${opts.otpCharset === 'alphanumeric' ? 'character' : 'digit'} code to <strong>${escapeHtml(maskedEmail)}</strong></p>
${opts.error ? '<p class="error">' + escapeHtml(opts.error) + '</p>' : ''}
${opts.error ? '<p class="error" role="alert">' + escapeHtml(opts.error) + '</p>' : ''}
<form method="POST" action="/account/verify-otp">
<input type="hidden" name="csrf" value="${escapeHtml(opts.csrfToken)}">
<input type="hidden" name="email" value="${escapeHtml(opts.email)}">
Expand Down
13 changes: 11 additions & 2 deletions packages/auth-service/src/routes/choose-handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
? `<div class="error" id="error-msg">${escapeHtml(error)}</div>`
? `<div class="error" id="error-msg" role="alert">${escapeHtml(error)}</div>`
: `<div class="error" id="error-msg" style="display:none;"></div>`

return `<!DOCTYPE html>
Expand Down Expand Up @@ -565,10 +569,15 @@ export function renderChooseHandlePage(
spellcheck="false"
minlength="5"
maxlength="20"
aria-describedby="handle-status"
>
<span class="handle-suffix">.${escapeHtml(handleDomain)}</span>
</div>
<div class="status" id="handle-status"></div>
<!-- Availability feedback is rewritten on every debounced
keystroke, so it must announce politely rather than
interrupting the user mid-type. Without this, screen
reader users get no availability feedback at all. -->
<div class="status" id="handle-status" role="status" aria-live="polite"></div>
</div>
${showRandomButton ? `<button type="button" id="random-btn" class="btn-secondary">Generate random handle</button>` : ''}
<button type="submit" id="submit-btn" class="btn-primary">Create</button>
Expand Down
113 changes: 81 additions & 32 deletions packages/auth-service/src/routes/login-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,7 @@ export function renderLoginPage(opts: {
${logoHtml}
<h1 id="heading">${opts.initialStep === 'otp' ? 'Enter your code' : 'Sign in'}</h1>

<div id="error-msg" class="flash-msg" style="display:none;"></div>
<div id="error-msg" class="flash-msg" style="display:none;" role="status" aria-live="polite"></div>

${socialButtonsHtml}

Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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'); }
Expand All @@ -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');
}

Expand Down
4 changes: 2 additions & 2 deletions packages/auth-service/src/routes/recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ export function renderRecoveryForm(opts: {
<div class="container">
<h1>Account Recovery</h1>
<p class="subtitle">Enter the backup email address associated with your account.</p>
${opts.error ? '<p class="error">' + escapeHtml(opts.error) + '</p>' : ''}
${opts.error ? '<p class="error" role="alert">' + escapeHtml(opts.error) + '</p>' : ''}
<form method="POST" action="/auth/recover">
<input type="hidden" name="csrf" value="${escapeHtml(opts.csrfToken)}">
<input type="hidden" name="request_uri" value="${escapeHtml(opts.requestUri)}">
Expand Down Expand Up @@ -434,7 +434,7 @@ export function renderRecoveryOtpForm(opts: {
<div class="container">
<h1>Enter recovery code</h1>
<p id="code-help" class="subtitle">If a backup email matches, we sent a ${opts.otpLength}-${opts.otpCharset === 'alphanumeric' ? 'character' : 'digit'} code to <strong>${escapeHtml(maskedEmail)}</strong></p>
${opts.error ? '<p class="error">' + escapeHtml(opts.error) + '</p>' : ''}
${opts.error ? '<p class="error" role="alert">' + escapeHtml(opts.error) + '</p>' : ''}
<form method="POST" action="/auth/recover/verify">
<input type="hidden" name="csrf" value="${escapeHtml(opts.csrfToken)}">
<input type="hidden" name="request_uri" value="${escapeHtml(opts.requestUri)}">
Expand Down
1 change: 1 addition & 0 deletions packages/demo/src/app/components/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function LoginForm() {
<>
{errorMessage && (
<div
role="alert"
style={{
background: 'var(--theme-error-bg, #fef2f2)',
color: 'var(--theme-error-text, #dc2626)',
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/render-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function renderError(
<div class="page-wrap">
<div class="container">
<h1>${escapeHtml(title)}</h1>
<p class="error">${escapeHtml(message)}</p>
<p class="error" role="alert">${escapeHtml(message)}</p>
${startOverHtml}
</div>
${bodyExtra}
Expand Down
Loading