Appearance
[UX] members — Superadmin
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch:
develop@2c22f5a· Files reviewed: 6 Patterns: forms/selection-input
Summary
The Superadmin view is a hand-rolled, self-contained CRUD surface (no TableData.vue) for cross-org management: create/rename/deactivate organisations and sub-organisations, toggle feature modules, and grant the Administrateur role. It is functional and reasonably tidy, but the consequential actions it exposes are the most powerful in the app and it protects none of them: organisation/sub-org deactivation and module removal persist on a single unconfirmed click, a failed initial load renders as a misleading "no search match" empty state with no retry, and admin-role grants go through a raw window.prompt with no email validation, confirmation, or out-of-band notification. The superadmin route gate (router.ts:100, meta.superadmin → session.isSuperadmin) is the deliberate cross-org flag noted in the queue, not a defect.
Findings
1. Deactivating an org / sub-org / module persists on one click with no confirmation — High · MSG-05
Where: admin/src/client/views/Admin/Superadmin.vue:139-145 (org « Désactiver »), :184-189 (sub-org « Désactiver »), :207-213 + toggleModule (:366-369) What: toggleOrg, toggleSubOrg and toggleModule fire their PATCH immediately on click; the only feedback is a success toast. Deactivating an organisation disables an entire tenant (and, in effect, every sub-org and staff member under it); removing a module hides a whole feature area for a sub-org. None of these prompt first or state what will be lost. Why it matters: A superadmin can knock an organisation or a module offline for other users with a single misclick and no chance to reconsider. Reactivation exists, but the affected users experience an unannounced outage in the interim. This is exactly the destructive-action class MSG-05 governs. Fix: Gate toggleOrg/toggleSubOrg (at least the active→inactive direction) and module removal behind a confirm dialog that names the target and its consequence ("Désactiver « BCC Paris » et ses 3 sous-organisations ?"). Reactivation and enabling a module can stay one-click.
2. Failed initial load renders as a misleading empty state, with no error surface or retry — High · MSG-06 (proposed)
Where: admin/src/client/views/Admin/Superadmin.vue:304-316 (load) and :99-103 (empty state) What: load() uses try { … } finally { loading.value = false } with no catch. api.execute throws on any non-2xx (lib/apiRequest.ts:23-30). So when /api/superadmin/orgs fails, the rejection is swallowed as an unhandled promise, loading flips to false, orgs stays [], and the template renders EmptyState reading « Aucune organisation ne correspond à la recherche. » — even though no search is active and the real cause is a failed fetch. There is no error message and no retry control. Why it matters: A superadmin whose list fails to load is told, falsely, that no organisation matches their (empty) search. Believing the platform is empty, they could start creating organisations that already exist. This is the members MSG-06 theme (loading gates / empty state on failure; see PROJECT-LEVEL.md) at a distinct site. Fix: Add a catch that sets an error ref, and render a dedicated error state with a retry button when it is set. Distinguish "empty because filtered" from "empty because unloaded/failed" in the empty-state copy.
3. Admin-role grant uses window.prompt with no email validation, no confirmation, no out-of-band notice — High · SEC-04 / MSG-05
Where: admin/src/client/views/Admin/Superadmin.vue:371-382 (promptGrant) What: Granting the Administrateur role to a sub-org is done via window.prompt(...); the raw string is trimmed and POSTed to /grant with a hardcoded role UUID. There is no email-format validation, no confirmation of a security-relevant privilege grant, and the « Administrateur ajouté » success toast fires on any 2xx regardless of whether the address maps to a real user. Nothing notifies the granted person out of band. Why it matters: Elevating someone to administrator is the highest-impact action on this screen and it has the least friction — a single typo silently grants (or appears to grant) admin to the wrong or a non-existent address, and the granted user gets no notification (SEC-04). window.prompt is also unstyled and inconsistent with the rest of the UI. Rated High rather than Blocker because exploitation requires an already-authenticated superadmin, but the missing confirmation on a privilege change plus the possibly-false success are real harm. Fix: Replace the prompt with an inline form: a labelled email field with format validation, a confirmation summarising who gets what role where, and a server-confirmed result (surface "invited"/"already had access"/"unknown user" distinctly). Ensure the backend notifies the granted user out of band.
4. A single global busy flag disables every action button during any mutation — Medium · FORM-06
Where: admin/src/client/views/Admin/Superadmin.vue:282 (one useMutationWithToast instance) driving :disabled="busy" on every button (:91-96, :131-145, :176-189, :207-213, :239-246) and every module checkbox What: All mutations share one busy ref, so while any one PATCH/POST is in flight, every Enregistrer / Désactiver / Créer / Ajouter / Admin button and every module checkbox across all org cards is disabled. A just-clicked button that disables itself drops focus to <body> (FORM-06), and unrelated controls flicker inert. Why it matters: Keyboard focus is lost after each action, and the whole page appears to freeze during a single save. Note the underlying library BccButton's :disabled behaviour is @bcc-code/component-library-vue internals — not readable here (node_modules absent) — but the template-level use of one shared busy across the whole view is the assertable defect. Fix: Scope busy state per-row/per-action, and prefer aria-busy + an in-handler guard over disabled on the activated control, so focus is retained.
5. Several inputs are labelled only by placeholder — Medium · FORM-01
Where: search :15-20, header churchId :120-124, create-org BccInputs :81-90, new-sub-org :234-238What: The search box, the per-org churchId field, both create-organisation fields, and the new-sub-org field carry only a placeholder (the new-sub-org field has an adjacent <label> but it is not associated via for/id). The org/sub-org name fields at :114-118 / :162-167 fare a little better via a title attribute, but that is not a visible persistent label. Why it matters: Placeholders vanish on input and are inconsistently announced; a screen-reader user tabbing into these fields, or any user who has started typing, has no persistent label. FORM-01 requires a programmatically associated label. Fix: Add associated <label>s (visible or sr-only) or pass the library label prop; associate the existing new-sub-org label with its input.
6. Custom status-filter dropdown lacks disclosure semantics and Escape/focus handling — Medium · A11Y-03
Where: admin/src/client/views/Admin/Superadmin.vue:22-51What: The « Filtrer » control is a hand-rolled popover: the trigger has no aria-expanded/aria-haspopup, the panel has no role="menu", it is dismissed only by a click-catching backdrop <div> (:30-33) with no Escape handler, and focus is neither moved into the panel nor returned to the trigger on close. (The forms/selection-input pattern calls out keyboard open/close and announced state as baseline for custom selects.) Why it matters: A keyboard or screen-reader user gets no announcement that a menu opened, cannot dismiss it with Escape, and the backdrop dismissal is mouse-only. The option <button>s themselves are reachable, so this is a partial rather than total barrier. Fix: Add aria-expanded/aria-haspopup to the trigger, role="menu"/menuitem semantics, an Escape-to-close handler, and focus movement into and back out of the panel — or use the library's select/menu primitive.
7. Unsaved inline edits are silently discarded when any action triggers a reload — Medium · proposed FORM-12(d) (warn before discarding entered form data)
Where: admin/src/client/views/Admin/Superadmin.vue:318-320 (run calls load on success) vs. inline header edits at :114-124What: Org name and churchId are edited in place (v-model="org.Nom") and persist only via that card's « Enregistrer ». But every structural mutation — createOrg, toggleOrg, createSubOrg, toggleSubOrg, promptGrant — calls load() on success, which replaces orgs.value wholesale. So typing a new name into org A's field and then clicking any action on org B silently reverts org A's field to the server value with no warning. Why it matters: Entered-but-unsaved work vanishes without notice — a data-loss friction the author partly anticipated for sub-org patches (:349-361) but not for the header fields. Fix: Either merge server data non-destructively on reload (preserve dirty fields), or warn before a reload that would discard edits, or drop the reload on mutations that don't change list membership.
8. Create buttons are disabled while the form is invalid — Low · FORM-05
Where: admin/src/client/views/Admin/Superadmin.vue:91-96 (« Créer », :disabled="!newOrg.Nom.trim() || busy") and :239-246 (« Ajouter ») What: The create-organisation and create-sub-org submit buttons are disabled until a name is typed, giving the user nothing to act on when the form is incomplete. Minor here since these are single-field inline forms, but it is the FORM-05 pattern. Fix: Keep the button enabled and surface a short "Nom requis" validation message on submit attempt.
9. Error toast detail can surface a raw HTTP <status> string — Low · MSG-02
Where: composables/useMutationWithToast.ts:30-35 (detail = e.message), messages sourced from lib/apiRequest.ts:24-26What: On a mutation failure the toast summary is a human French sentence (good), but detail is e.message, which falls back to HTTP 500 when the server sends no error/message. Users can see the raw technical string as secondary detail. Why it matters: Low, because the primary summary is human and the detail is secondary — but a raw status code still reaches users. (Widget-side raw-HTTP strings are the project-level MSG-02 finding; this is the admin path.) Fix: Map known statuses to human sentences; suppress the raw fallback in detail.
Unverified
- A11Y-01 (contrast) — the muted greys used heavily here (
text-neutral-400/-500on white for the description, empty state, slug<code>, and placeholders;text-2xs/text-3xsmicro-labels) are plausibly at risk but cannot be settled from class names. Needs a contrast tool on the rendered page. - A11Y-06 (short viewport / mobile keyboard) — the header uses
flex-wrapand the cards stack, but the many-control org headers and the module pill grid need a rendered narrow/short viewport to judge. BccButton/BccInputinternals —@bcc-code/component-library-vueis not installed (no node_modules), so the actual disabled/focus and label behaviour of finding 4/5's components is asserted only at the template level.- Does deactivation actually revoke access? Per the standing cross-project hypothesis (PROJECT-LEVEL.md): whether deactivating an org/sub-org ends existing sessions or is a display flag only is server-side (
/api/superadmin/*) and not readable here. Worth confirming in the API audit. - Grant success truthfulness — whether the
/grantendpoint 200s for an unknown email (making finding 3's toast lie) is server-side and unverified.
Baseline additions
- FORM-12(d) — warn before discarding entered form data (finding 7): a reload/refresh that would overwrite unsaved user input must preserve dirty fields or warn first. Already proposed under the FORM-12 collision set in PROJECT-LEVEL.md; this is another instance. Orchestrator to renumber.
- No other new rules; CONTENT-01 is not-applicable — see project-level i18n finding (members has no i18n layer by design). NAV-03 fails project-wide (static « BCC Nancy Admin » title) — see PROJECT-LEVEL.md, not re-filed here.
Cross-project note
- MSG-05 unconfirmed destructive toggles (finding 1) and MSG-06 failed-load-as-empty-state (finding 2) are prime candidates for the other admin surfaces. customer-portal and tt-time-tracker both have org/user management screens with enable/disable actions; the "archive is a display flag / no confirm" pattern already bit tt-time-tracker (Blocker) and is an open hypothesis for members #25 Users & roles and customer-portal roles.
- Grant-with-no-confirmation/notification (finding 3) pairs with the last-admin-guard and offboarding-revocation hypotheses in PROJECT-LEVEL.md — any role/permission surface in the other three should be checked for the same missing confirmation + SEC-04 notification.
- Global
busydisabling all controls (finding 4) is a hand-rolled-mutation-helper shape; grepuseMutationWithToastusage across members admin views for the same single-instance-per-view pattern.