Appearance
[UX] members — Users & roles
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch:
develop@2c22f5a· Files reviewed: 9 Patterns: data-display/table (consulted; forms/multi-select-input n/a — feature uses single native<select>s, not a multi-select; authentication/user-profile not applicable — no profile surface here)
Summary
Two hand-rolled tables (Users.vue users tab + RoleAccessMatrix.vue roles tab) outside the shared TableData.vue, so the project-level TableData findings do not apply and these views must be judged on their own. The core interaction — assigning a role via a per-row native <select> — is unlabelled for assistive tech and applies (including full access removal) instantly with no confirmation. Both views also silently render an empty table when their fetch fails. On the two standing cross-project hypotheses (offboarding/revocation and last-admin guard), the client offers no disable control and no self-demotion guard; the server-side half is unverifiable from this code and is reported as such.
Findings
1. Per-row role <select> has no accessible name — High · FORM-01
Where: admin/src/client/views/Admin/Users.vue:123-139 (also the three invite controls at :24, :33, :48) What: The role-assignment <select> rendered in every user × sub-org cell has no <label for>, no aria-label and no aria-labelledby. The Rôle — {Nom}<th> is not programmatically associated with the control (native table semantics do not name form fields). A screen-reader user tabbing the grid hears only "combobox, Aucun accès" with no idea which user or which sub-organisation the control governs. The invite form's three labels (<label class="text-xs"> at :23, :32, :47) are likewise not tied to their inputs — the inputs carry no id. Why it matters: The primary action of the whole feature — granting and revoking access per sub-org — is operable but unintelligible to assistive-tech users, who cannot tell which row they are about to change. Corroborated by the table pattern's "Poor Accessibility Structure" anti-pattern. Fix: Give each row <select> an aria-label built from user name + sub-org (e.g. :aria-label="Rôle de ${name} — ${so.Nom}"). Give the invite inputs ids and point each <label for> at them (or wrap the control in the <label>).
2. Role change / access removal applies instantly with no confirmation — Medium · MSG-05
Where: admin/src/client/views/Admin/Users.vue:127 (@change="setRole(...)"), handler :226-242What: Changing a user's role — including selecting "Aucun accès", which strips that user's access to the app for that sub-org — fires on the native select's change event and commits immediately, surfacing only a success toast. Contrast RoleAccessMatrix.deleteRole (:331) and the rename field, where the destructive path goes through useConfirm. A mis-click or keyboard mis-selection on the combobox silently revokes someone's access with no "what will be lost" step and no undo. Why it matters: Access removal is consequential and easy to trigger by accident on a dense grid of comboboxes; the baseline requires irreversible/ destructive actions to confirm and state the consequence first. Fix: When the new value is empty ("Aucun accès") — or is a downgrade — route through confirm({ danger: true, message: "Retirer l'accès de … ?" }) before the PUT, mirroring role deletion.
3. Failed load renders as a silent empty table — Medium · MSG-06 (see PROJECT-LEVEL.md members)
Where: admin/src/client/views/Admin/Users.vue:190-203 and admin/src/client/components/app/RoleAccessMatrix.vue:244-252 / :339-345What: load() in both views has a try/finally with no catch. If /api/org/users, /api/org/roles or /api/org/permissions rejects, loading flips to false, the data refs stay empty, and the view renders a table that is just headers with an empty <tbody> (Users) or an empty matrix (roles) — no error, no retry, no empty-state copy even for a legitimately-empty result. Why it matters: An admin whose fetch failed sees "no users / no roles" and may conclude the org is empty and re-invite, or assume permissions were wiped. This is the members MSG-06 theme, here in a hand-rolled view rather than TableData.vue, so the shared-component fix does not cover it. Matches the table pattern's "Missing Empty States" anti-pattern. Fix: Add a catch that sets an error ref and render an error state with a retry (and a distinct genuine empty state); do not treat rejection as "no data".
4. Invite submit is disabled while invalid — Medium · FORM-05, FORM-06
Where: admin/src/client/views/Admin/Users.vue:62What: :disabled="!invite.email || !invite.subOrganisationId || !invite.role || inviting". The button is disabled until all three fields are filled, so a user who has missed one field gets a dead, unexplained control and no message saying what is missing (FORM-05). It is also disabled during the in-flight request (inviting), which drops focus off the just-activated button (FORM-06). Why it matters: Disabled submits give the user nothing to act on; the "calm, approachable" non-tech audience gets no guidance on why "Inviter" is inert. Fix: Keep the button enabled; validate on click and show which field is missing. For the in-flight state use aria-busy + a guard in sendInvite rather than disabled.
5. Role picker conveys selection by colour only, no programmatic state — Medium · A11Y (proposed A11Y-SELECT-STATE)
Where: admin/src/client/components/app/RoleAccessMatrix.vue:14-25What: The role "pills" are <button>s whose selected state is expressed only by swapped colour classes (border-brand-600 bg-brand-50 text-brand-700). There is no aria-pressed, aria-current, or role="tab"/aria-selected, so which role is active is not exposed to assistive tech and is distinguished sighted-only by hue. (The BccSelectButton tab switcher in Users.vue:8 is a library component and its ARIA is Unverified — see standing node_modules caveat.) Why it matters: A screen-reader or low-vision user cannot tell which role's permission matrix is currently shown. Fix: Add :aria-pressed="selectedRoleId === r.id" (or a proper tablist), and pair the colour change with a non-colour affordance.
6. Loading state is not announced — Low · MSG-01, CONTENT-04
Where: admin/src/client/views/Admin/Users.vue:69-74 and RoleAccessMatrix.vue:2-7 (<div>Chargement...</div>) What: The "Chargement..." placeholder is a bare <div> with no role="status" / aria-live="polite". A screen-reader user gets no notification that the view is loading or that it has finished. Fix: Wrap the loading text in role="status"; it already names what is loading, which satisfies CONTENT-04.
Cross-project hypothesis answers (reported per PROJECT-LEVEL.md)
Offboarding / revocation — client-side gap; server-side UNVERIFIED. The Users table shows a read-only Statut tag ("actif"/"désactivé", Users.vue:141-145) but there is no control to set it — you cannot disable or archive a user from this screen. The only way to remove access is to set each sub-org cell to "Aucun accès" one at a time (no bulk "remove user"), which is finding #2. Whether clearing roles, or the "désactivé" status, actually terminates existing sessions is enforced server-side (PUT /api/org/users/:id/roles) and cannot be determined from this checkout — flagged Unverified, not asserted.
Last-admin / self-demotion guard — no client guard; server-side UNVERIFIED. Nothing in Users.vue (setRole) or RoleAccessMatrix.vue (deleteRole, cycleAccess) prevents a superadmin from clearing their own last admin role or deleting the admin role. session.store exposes the current user id, but neither view compares it to the row being edited, so the UI offers no last-admin protection. Whether the API rejects the self-lockout is not visible here — Unverified.
Unverified
- A11Y-01 (contrast) — always Unverified; the
text-neutral-300"—" no-access glyph (RoleAccessMatrix.vue:138,:172) andtext-2xs text-neutral-500badges look like contrast risks but need a rendered check. - A11Y-06 (responsive / short viewport) — always Unverified; both tables rely on
overflow-x-auto, and the users table adds one column per sub-org, so width growth on small viewports needs a rendered check. BccSelectButton/BccTag/BccInput/BccDialoginternals (ARIA roles, focus handling) —@bcc-code/component-library-vueis not installed in this checkout (standing caveat); library-level claims left Unverified.- Server-side session revocation and last-admin enforcement (above).
Baseline additions
- A11Y-SELECT-STATE (proposed): a control's selected/pressed/current state must be exposed programmatically (
aria-pressed/aria-current/aria-selected), not by colour alone. Overlaps the separately-proposed A11Y-07(d)/(e) — reconcile. - MSG-06 (already in flight): a failed fetch must not render as an empty state — applies here in a hand-rolled view, not only in
TableData.vue. - Otherwise none. CONTENT-01: not-applicable — see project-level i18n finding.
Cross-project note
- Unlabelled in-cell form controls (#1) and destructive action with no confirm (#2) are likely in any admin role/permission grid — check customer-portal #35 Roles & permissions and tt-time-tracker #21 Organizations, both of which assign roles inline.
- Last-admin / self-demotion (hypothesis): playout's Admin management already fails this; members has no client guard either. Same open question for customer-portal #35 and tt-time-tracker #21.
- Offboarding revocation (hypothesis): tt-time-tracker FAILS, customer-portal PASSES (reference). members' answer is server-side and unresolved here — needs an API-side follow-up to complete the table.