Skip to content

fix(a11y): accessibility fixes across the ui, and an accessible show password button - #137

Merged
BatLeDev merged 15 commits into
masterfrom
fix-a11y-login-and-account
Sep 1, 2026
Merged

fix(a11y): accessibility fixes across the ui, and an accessible show password button#137
BatLeDev merged 15 commits into
masterfrom
fix-a11y-login-and-account

Conversation

@BatLeDev

@BatLeDev BatLeDev commented Aug 13, 2026

Copy link
Copy Markdown
Member

Accessibility fixes on the pages served by simple-directory, coming from the RGAA re-audit of a customer portal, plus two regressions of the Vuetify 4 upgrade found along the way.

Also closes the "show the password behind an eye icon" ticket, which turned out to belong here.

Login page

  • the logo alternative was the generic Site logo i18n string, it now uses the site title when there is one (RGAA 1.7)
  • the served document had no <title>, only the one the SPA sets at runtime, so the W3C validator rejected it. The built index.html now carries a {SITE_TITLE} placeholder filled by the site title, HTML-escaped before injection. title is optional on the site schema, so an empty one falls back to the default rather than producing <title></title>
  • vite injects <meta property="csp-nonce" nonce="…"> for html.cspNonce without the content attribute the HTML spec requires next to property, the second error the validator reported. A small transformIndexHtml plugin adds it (RGAA 8.2)
  • the three fields that all carried id="password" get a unique id, they sit in the DOM at the same time inside the window items

Showing the password

The login password field had no way to reveal what was typed, and the two new password fields relied on a bare v-icon with a click handler: an <i> element, so not focusable, not operable with a keyboard, with no accessible name and no state restitution. The two confirmation fields had nothing at all and shared their reveal state with the field above them.

A new password-reveal component now sits in the append-inner slot of the five password fields. It renders a real <button type="button">, carries a title and a matching aria-label that follow the state, and states the new state in a screen reader only live region without ever exposing the password itself.

Deliberately no re-masking after a delay, which the ticket suggested. RGAA has no criterion on revealing a password, and the only one that speaks about delays is 13.1, "l'utilisateur a-t-il le contrôle de chaque limite de temps modifiant le contenu" (WCAG 2.2.1 / 2.2.2) — an unrequested timer works against it, and five seconds is not enough to re-read a long password. No re-masking on blur either, since blur fires when reaching for the button. The password is re-masked when the form is submitted, which is what the GOV.UK component does, and the DSFR password component has no timer either.

Account page

  • the email address was displayed in a disabled field, dimmed to 38% opacity, dropping its contrast well below 4.5:1. It is now readonly, which keeps it readable and focusable, with a hint saying it cannot be changed (RGAA 3.2)
  • the avatar image had no alt attribute (RGAA 1.1)

The page keeps starting its titles at h2: it is always embedded in an iframe, the h1 belongs to the host page.

Organization page

  • the four menus opened by a v-fab (create a department, edit the departments, invite a partner organization) had no accessible name: v-fab renders a <div> root and only forwards the props v-btn declares, so title and the menu activator's v-bind="props" (aria-haspopup, aria-expanded, id, the click handler) all landed on that wrapper. The button was neither named nor keyboard operable. They go back to v-btn, like the invite member button right next to them, which was already correct (RGAA 7.1, 7.3)
  • the organization, department, member and partner avatars were v-img without alt, so img elements with no alt attribute at all (RGAA 1.1)

Naming every icon-only button

An icon-only button has no text content, so its accessible name came from the title attribute alone. That is only the last resort of the accessible name computation and auditors flag it, while the tooltip stays useful to sighted users, so both are kept: 35 buttons get an aria-label mirroring their title.

Five buttons on the admin pages had neither, so no accessible name at all: editing a user email, dropping a user 2FA, editing the created orgs quota, and opening an organization from the two organization tables. They are named from the labels their own dialogs already use, plus a new common.seeTitle.

Translations

Reviewing the aria-label above surfaced one that mirrored a title holding a raw french string, so the six locale files and every template literal went through an audit.

The locale files themselves had no gap, but they did hold one wrong value: api/i18n/pt.js carried the french htmlCaption of one of the login mails, while the very same sentence sits translated fourteen lines above it in that same file. Portuguese users received a bilingual mail. Found by diffing long values that stayed identical to the french ones.

The templates held literals that never went through i18n at all:

  • the contact page, which any user can reach, had its three field labels, its send button and its success message written in french, and its heading pointed at common.createOrganization, so the contact page announced "create an organization"
  • the three invitation menus labelled their redirect select in french while common.redirectSite was already translated in the six locales right there, unused
  • the password lists page had its two help paragraphs, its file input label, its list subtitles and its delete confirmation in french, plus a $t('charger') that is not a key path and resolved to nothing
  • the sites admin table had the raw Se connecter sur le site on the login button

Every one of them now goes through a key present in the six locales. The tree is checked by an audit that asserts no key is missing in any direction, no template literal is left in a title / label / aria-label / placeholder / subtitle, no $t() call is missing its key path, and no long value stayed identical to the french one: 394 keys, six locales, no gap.

Vuetify 4 upgrade leftovers

  • the 2FA roles select and the members role filter were fed the raw role keys (admin, contrib, user) while the readable labels sit right next to them in rolesLabels, which the same page even lets you edit. Both now build their items from those labels, with the configured defaults as fallback, and the role shown under each member follows
  • the upgrade replaced the removed default uppercase on buttons with text-transform: capitalize, which capitalizes every word: "Renouveler Le Mot De Passe". The rule is dropped and buttons render their label as written. That left the main action buttons inconsistent, four carrying an inline text-transform: uppercase and six none at all, so on the login page a single button stood out in capitals. Every bottom right action button is uppercase again, through the text-uppercase utility class: no inline styles and no text-transform declaration of our own left in the ui

Security

The site title reaches the served HTML through getSiteExtraParams, which microTemplate applies before the CSP_NONCE pass, so a title holding the literal {CSP_NONCE} came back with the request nonce inside <title>. And microTemplate interpolates via String.replace, so $&, $` and $' in a title were read as replacement patterns. { is neutralized and $ doubled alongside the existing HTML escaping.

Findings from the RGAA re-audit of a customer portal, on the two pages
served by simple-directory inside it.

Login page:
- the logo alternative was the generic "Site logo" i18n string, it now
  uses the site title when there is one (RGAA 1.7)
- the served document had no <title>, only the one the SPA sets at
  runtime, so the W3C validator rejected it: the built index.html now
  carries a {SITE_TITLE} placeholder filled by the site title (RGAA 8.2)
- vite injects <meta property="csp-nonce" nonce="…"> for html.cspNonce
  without the content attribute the HTML spec requires next to property,
  which is the second error the validator reported (RGAA 8.2)

Account page:
- the email address was displayed in a disabled field, dimmed to 38%
  opacity, which drops its contrast well below 4.5:1. It is now readonly,
  which keeps it readable and focusable, with a hint saying it cannot be
  changed (RGAA 3.2)
- the avatar image had no alt attribute (RGAA 1.1)

The page keeps starting its titles at h2: it is always embedded in an
iframe, the h1 belongs to the host page.
- the four menus opened by a v-fab (create a department, edit the
  departments, invite a partner organization) had no accessible name:
  v-fab renders its title on the wrapping div, not on the button it
  contains. They go back to v-btn, like the invite member button right
  next to them, which was already correct (RGAA 7.1)
- the bell button of the notification menu had no name at all, it gets a
  title (RGAA 7.1)
- the organization, department, member and partner avatars were v-img
  without alt, so img elements with no alt attribute at all (RGAA 1.1)
- the 2FA roles select and the members role filter were fed the raw role
  keys (admin, contrib, user) while the readable labels sit right next to
  them in rolesLabels, which the same page even lets you edit. Both now
  build their items from those labels, with the configured defaults as
  fallback, and the role shown under each member follows
- the Vuetify 4 upgrade replaced the removed default uppercase on buttons
  with text-transform: capitalize, which capitalizes every word:
  "Renouveler Le Mot De Passe". The rule is dropped, buttons render their
  label as written, and the four labels that were written in lowercase
  because they relied on it are capitalized
@github-actions github-actions Bot added the fix label Aug 13, 2026
@BatLeDev
BatLeDev marked this pull request as draft August 13, 2026 15:45
Carries the boundIp session field that lib-express 1.25.0 reads, without
which tsc rejects the assignment in setSessionCookies.
- Account page email input was changed from disabled to readonly for
  accessibility (RGAA 3.2 contrast); assert readonly/non-editable instead
  of disabled.
- Raise navigationTimeout to 15s for the e2e project: full page loads
  (incl. superadmin login) can exceed 5s when the whole suite loads the
  server, causing flaky failures.
@BatLeDev
BatLeDev marked this pull request as ready for review August 14, 2026 08:57
The avatar upload field used a single label ("chargez un nouvel avatar")
in the 4 places load-avatar is mounted, so users editing an organization
or a department thought they were replacing their own avatar. The label
is now computed from the owner: user, organization, or department (with
the organization's configurable department label).

The label is also the accessible name of the file input, so a generic
one left screen reader users with no way to tell the two apart either.

While at it, imperative labels are turned into infinitives to match the
convention already used by the vast majority of controls, in every
locale: dialog titles, links, and mail buttons. Prose addressed to the
user keeps the imperative. A few missing hyphens in French inverted
forms are fixed too, and the German department actions get the right
word order.
Two labels rendered a placeholder literally: the Spanish user email
dialog translated the placeholder itself ({nombre} instead of {name}),
and the German member deletion message used {name} while the caller
passes {org}.

deleteMyselfAlert and deleteMyselfCheck had lost their placeholders in
de/es/it/pt, so the account deletion confirmation named neither the
cancellation delay nor the account being deleted. The Italian
deleteMyselfCheck was in Spanish.

The rest is spelling and agreement, mostly French: "Modifer",
"Propriéraire", "identitié", "aucun role", "boite" -> "boîte",
"chaine" -> "chaîne", "le champs", "débloquage", "vis à vis",
"une invitation a été envoyé", "que vous avez reçu", "un bref interval",
"vous même", a duplicated period, and a stray quote at the start of a
config description. In English, "Your are not a member in any
organization" and "If your delete your account".

editUserEmailTitle was also still an imperative in fr/de/it/pt, unlike
the two sibling titles of the same screen.
…aceholder

The site title reaches the served HTML through getSiteExtraParams, which
microTemplate applies before the CSP_NONCE pass. A title holding the literal
{CSP_NONCE} therefore came back with the request nonce inside <title>. And
microTemplate interpolates via String.replace, so $&, $` and $' in the title
were read as replacement patterns.

Neutralize '{' and double '$' alongside the existing HTML escaping.
The inject-site-context plugin mirrors the express middleware in dev, but was
missing the new SITE_TITLE param, so the browser tab showed the raw
{SITE_TITLE} placeholder.
title is not a VImg prop, so it landed on the wrapper div and left the 2FA qr
code <img> without any alternative. Move it to alt, which VImg forwards to the
img element. The site logo next to the host name is decorative, mark it as such.
…mpty

title is optional on the site schema and has no minLength, so a site saved with
an empty title produced <title></title>. That fails the very criterion the
placeholder was added for. ?? only guards undefined and null, use || instead.
An icon-only button has no text content, so its accessible name came from the
title attribute alone. That is only the last resort of the accessible name
computation and RGAA auditors flag it, while the tooltip title carries remains
useful to sighted users. Keep both: mirror each title into an aria-label.

Five buttons on the admin pages had neither, so no accessible name at all:
editing a user email, dropping a user 2FA, editing the created orgs quota and
opening an organization from the two organization tables. Name them from the
labels their own dialogs already use, plus a new common.seeTitle.
Removing the global .v-btn text-transform rule left the main action buttons
inconsistent: four of them carried an inline text-transform: uppercase and the
six others none at all, so on the login page a single button stood out in
capitals.

Uppercase every bottom right action button and go through the text-uppercase
utility class, which drops the inline styles and leaves no text-transform
declaration of our own in the ui.
…field

The login password field had no way to reveal what was typed, and the two new
password fields relied on a bare v-icon with a click handler: an <i> element,
so not focusable, not operable with a keyboard, with no accessible name and no
state restitution. The two confirmation fields had nothing at all, and shared
their reveal state with the field above.

Introduce password-reveal and put it in the append-inner slot of the five
password fields. It renders a real button, carries a title and a matching
aria-label that follow the state, and states the new state in a screen reader
only live region without ever exposing the password itself.

Deliberately no re-masking after a delay: an unrequested time limit works
against RGAA 13.1 and five seconds is not enough to re-read a long password.
Neither on blur, which would fire when reaching for the button. The password is
re-masked when the form is submitted, as the GOV.UK component does.

Also give the three fields that all carried id="password" a unique id, they are
in the DOM at the same time inside the window items.
@BatLeDev BatLeDev changed the title fix(ui): accessibility fixes on the login, account and organization pages fix(a11y): accessibility fixes across the ui, and an accessible show password button Sep 1, 2026
@github-actions github-actions Bot added fix and removed fix labels Sep 1, 2026
The audit of the six locale files started from an aria-label I mirrored from a
title holding a raw french string, and turned up more than that one.

api/i18n/pt.js carried the french htmlCaption of one of the login mails, while
the very same sentence sits translated a few lines above it in the same file:
portuguese users received a bilingual mail.

The templates held french literals that never went through i18n at all. The
contact page, which any user can reach, had its three field labels, its send
button and its success message written in french, and its heading pointed at
common.createOrganization, so it announced "create an organization". The three
invitation menus labelled their redirect select in french while common
.redirectSite was already translated right there. The password lists page had
its two help paragraphs, its file input, its list subtitles and its delete
confirmation in french, plus a $t('charger') that resolved to nothing.

Every string now goes through a key present in the six locales: 394 keys, no
gap in any direction.
@github-actions github-actions Bot added fix and removed fix labels Sep 1, 2026
@BatLeDev
BatLeDev merged commit 852ebf1 into master Sep 1, 2026
4 checks passed
@BatLeDev
BatLeDev deleted the fix-a11y-login-and-account branch September 1, 2026 09:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant