Appearance
[UX] members — Member-emulation panel
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch:
develop@2c22f5a· Files reviewed: 19 Patterns:forms/search-field
Summary
The emulation console is a staff tool that mints a real member session and renders the six member-facing widgets fully live — event registration, SumUp checkout, signed waiver PDFs, membership and cotisation changes are all reachable from the preview pane. The server side is sound (the token is minted behind SessionGuard + OrgGuard + AdminGuard, org-isolated, and every widget write is re-scoped server-side by widgetMemberId), so the defects are all in what the operator can see. The single most important one: the panel never names the person being emulated — it identifies them only by a raw PersonID typed into an unlabelled box — and switching that PersonID does not reset the shared Pinia stores, so the previous member's registrations keep rendering under the new member's id.
Findings
1. Switching the emulated member leaves the previous member's data on screen — Blocker · EMU-02 (proposed)
Where: widgets/src/components/admin/AdminPanel.vue:27 · widgets/src/main.ts:8 · widgets/src/widgets/my-events/stores/events.store.ts:148 · widgets/src/stores/session.store.ts:10 · widgets/src/stores/family.store.ts:10
What: The preview uses :key="selectedComponent + selectedUser + selectedYear + token", which remounts the component when the PersonID changes. Pinia, however, is installed once on the app (main.ts:8), so every store survives that remount and nothing resets it. Three stores then carry member A's data into member B's session:
events.store.ts:148—myInscriptionByEventId.value = { ...myInscriptionByEventId.value, ...nextSingles }merges rather than replaces. Events that A is registered to but B is not keep A's inscription.EventTile.vue:190reads exactly this map to decide whether the tile shows "registered".family.store.ts—get()returns early when the new member has noFamille_parent, sodatastays as A's family.Checkout.vue:201returnsfamily.membersas the list of people who can be signed up, so the checkout can offer A's children while the panel says B.session.store.ts:10—data(the member record driving thecategoryU16/O16/O18 computation) is only overwritten ifloginApisucceeds; if it throws (see finding 7) A's record stays.
Why it matters: The tool has exactly one job — show one member's state faithfully — and it shows a blend of two. An operator diagnosing "why can't this member register?" reads another member's registration state and answers the wrong question. Acting on the stale row is caught server-side (widget-events.controller.ts:96-98 rejects an inscription the JWT's member does not own) and surfaces as a raw "Accès refusé", which reads as "this member's cancel button is broken" rather than "you are looking at the wrong person's data".
Placed at Blocker rather than High because the feature produces a wrong answer under normal use, not merely a confusing one — no error state, no unusual input, just typing a second PersonID.
Fix: Reset the widget state whenever the emulated subject changes. Either $reset() the session/family/events/objectifs/memberships stores in the watch on selectedUser/selectedSuborg before minting, or give the preview pane its own Pinia instance per emulation (a keyed wrapper that calls createPinia()), and clear the module-level accessToken in widgets/src/api.ts:8 at the same time. Independently, fix the merge at events.store.ts:148 to replace rather than spread.
2. The emulated identity is never resolved to a person — High · EMU-01 (proposed)
Where: widgets/src/components/admin/AdminPanel.vue:12 (input), :53 (default value), :100 (token accepted)
What: The only identification of the emulated member anywhere in the UI is the digit string the operator typed. GET /api/org/widget-token looks the member up (api/src/auth/org-admin.controller.ts:49-55) and /api/widget/me returns the full member record into session.data, but neither name is ever rendered. No suggestion list, no "did you mean", no resolved-identity confirmation, no member name in the preview header. A typo that lands on another valid PersonID in the same organisation mints a token silently and renders that person's memberships, events and waivers as if the operator had asked for them. The forms/search-field anatomy expects a suggestion area and a status region precisely for this; both are absent.
Why it matters: This is the failure mode that turns emulation into harm. Staff act on what they see; if what they see is not the member they meant, the mistake is invisible until it reaches the member. Verifying you got the right person currently requires leaving the panel and looking the id up in the admin SPA.
Placed at High rather than Blocker because the feature does work for a correctly-typed id and no outsider can act on it — the harm needs an operator error to trigger.
Fix: Return { token, membre: { PersonID, Nom, Prenom, statut } } from widget-token and render the resolved name prominently above the preview ("Émulation de Marie Dupont (25107) — Nancy"). Better still, replace the raw id field with a member search (name or id) backed by the existing member query endpoint, so the operator picks a person rather than typing a key.
3. Emulated actions are indistinguishable from the member's own, and the member is never told — High · SEC-04
Where: api/src/auth/org-admin.controller.ts:56 · api/src/auth/widget-session.ts:49-53
What: The minted JWT carries { sub: personid, scope: "widget" } — no actor claim, no emulation flag. Everything written through it (an inscription, a confirmed payment status, a signed liability waiver PDF, a cotisation amount) is recorded as the member's own action. The only trace that an admin was behind it is this.logger.log("Widget token minted for member … by …") at org-admin.controller.ts:56 — an application log line, not a durable audit record (there is no audit table in api/src), and the member is never notified that their account was opened or acted on.
Why it matters: A member who is told "you signed this waiver" or "you registered for this camp" has no way to establish that they did not, and staff have no way to establish that they did. For a signed-PDF liability waiver on a minor, that is the one record that most needs to be attributable.
Placed at High per the severity note: this is a hardening/accountability gap requiring an insider, not something an outsider can act on directly.
Fix: Add an act (actor) claim to the widget JWT when it is minted through the emulation path, persist an emulation record (admin, member, sub-org, timestamp) server-side, and stamp writes made under an emulated session so they are distinguishable in the data. If emulated writes are ever intended in production, notify the member out of band as for any account change; if they are not intended, see finding 5.
4. Opening the panel immediately emulates a hardcoded real member — High · EMU-03 (proposed)
Where: widgets/src/components/admin/AdminPanel.vue:53 (ref<string>("25107")) · :105-108 (watch(..., { immediate: true }))
What: selectedUser defaults to the literal PersonID 25107 and the watch that mints tokens runs immediate: true. Loading /widgets therefore mints a widget token for member 25107 and renders their private data — memberships, event registrations, waivers, donation totals — before the operator has typed anything. Combined with finding 2 (no name displayed), the operator has no signal that the pane is showing a specific real person rather than a demo.
Why it matters: A real member's records are shown to every admin who opens the console, unrequested, and an emulation-mint log line is written against that member every time. Whoever 25107 is, they are the only member in the organisation whose data is exposed on page load.
Placed at High rather than Medium because it exposes a real member's private records with no operator intent; it is not merely an awkward default.
Fix: Default selectedUser to "", drop immediate: true, and render an explicit empty state in the preview pane ("Saisis un PersonID pour démarrer une émulation").
5. Nothing marks the preview as emulated, and there is no exit control — Medium · EMU-04 (proposed)
Where: widgets/src/components/admin/AdminPanel.vue:2-30
What: The only emulation marker is the <h2>Émulation widgets</h2> at :5, inside the left column. The preview pane itself carries no banner, no border, no member name, no "tu vois l'app en tant que …" strip. The layout is flex-col sm:flex-row, so below the sm breakpoint the marker column stacks above the preview — scroll down and the widget fills the screen with no emulation context at all. There is also no exit affordance: the only way to stop emulating is to clear an unlabelled text box, and doing so unmounts the component (v-if="selectedUser && token") without clearing the module-level accessToken in api.ts:8, the Pinia stores, or the 1-hour widget JWT.
Why it matters: The assignment's core question — "can the operator always tell they are emulating rather than acting as themselves?" — currently depends on the operator remembering which tab they are in. A widget rendered here is pixel-identical to the one a member sees on the public site.
Fix: Put a persistent, high-contrast strip directly above (or around) the preview: "ÉMULATION — <Nom> (<PersonID>) · <organisation>" plus a « Quitter l'émulation » button that clears accessToken, resets the stores and returns the pane to its empty state. Consider a read-only emulation mode as the default, with writes requiring an explicit opt-in toggle.
6. The Organisation and Year selectors do not reach five of the six widgets — Medium · FORM-inert (proposed — collides with FORM-12(c) in PROJECT-LEVEL.md)
Where: widgets/src/components/admin/AdminPanel.vue:15,20,27 · widgets/src/widgets/my-events/components/MyEvent.vue:29,41 · widgets/src/widgets/my-memberships/MyMembershipsBCC.vue:75,83 · .../MyMembershipsBActive.vue:92,101 · widgets/src/widgets/my-camps/components/MyObjectif.vue:26,29 · widgets/src/widgets/dons-progress/DonsProgress.vue:38,56
What: The panel passes :suborg="selectedSuborg || undefined", but five of the six previewed components read it as (props as any).suborg on a props object whose defineProps<…>() never declares suborg. Vue routes undeclared attributes to attrs, so props.suborg is undefined in every case and each component falls back to a hardcoded slug — "b-active" in four of them, "bcc" in MyMembershipsBCC. Only MyDecharges.vue:93 declares the prop and honours the selection. The same shape affects Year: MyEvent.vue:29 does not declare year, so session.init(props) sets year = Number(undefined) = NaN, which feeds the U16/O16/O18 category computation in session.store.ts:12-19.
Why it matters: An admin in an organisation whose sub-org slug is not b-active selects their org, sees the dropdown change, and gets a session for a different sub-org — or an outright UnauthorizedException("Organisation inconnue") from widget-session.ts:41 with no message on screen (finding 7). Two of the four visible controls silently do nothing.
Fix: Declare suborg (and year on MyEvent) in each component's defineProps, remove the (props as any) casts and the hardcoded slug fallbacks, and let a missing sub-org be an explicit error rather than a guess.
7. A blank preview pane means four different things, and network failures produce no message at all — Medium · CONTENT-04
Where: widgets/src/components/admin/AdminPanel.vue:27 (v-if="selectedUser && token") · :77-101 (fetchToken) · :105-108 (debounce)
What: The pane renders nothing at all when: no PersonID is entered; the 400 ms debounce has not elapsed; the mint request is in flight; the mint returned a non-2xx; or the widget's own loginApi threw. There is no spinner, no skeleton, no "recherche du membre…". Worse, fetchToken has no try/catch — await fetch(...) at :89 rejecting (offline, DNS, CORS, proxy error) throws inside a setTimeout callback, so tokenError is never set and the operator sees an empty pane with no explanation. The same is true one layer down: every widget calls await session.loginApi(...) in onMounted with no handler (MyEvent.vue:41, DonsProgress.vue:56, MyObjectif.vue:29, MyMembershipsBCC.vue:83, MyMembershipsBActive.vue:101), so a rejected widget login aborts the rest of onMounted and leaves the widget in its initial state — for MyEvent that is EventNotFound.vue, i.e. "this member has no events", which is a plausible and wrong answer.
Why it matters: "Emulation failed" and "this member genuinely has nothing" are the two answers the tool exists to distinguish, and it renders them identically.
Fix: Wrap fetchToken in try/catch and set tokenError on network failure; add a loading ref covering debounce + mint and render a named waiting state; wrap each widget's loginApi call and surface the failure in the panel rather than swallowing it.
8. Error text is not announced; the preview swap is silent — Medium · MSG-01
Where: widgets/src/components/admin/AdminPanel.vue:21 · :26-28 (<Transition> around the preview)
What: <p v-if="tokenError" class="text-sm text-red-600"> carries no role="alert" and no live region. The needsLogin branch (:6-9) likewise replaces the whole control group with no announcement. The preview appearing or disappearing — the panel's single most important state change — is an unlabelled DOM swap.
Why it matters: A screen-reader operator gets no indication that the mint failed, that they have been logged out, or that emulation of a different member has started (WCAG 4.1.3).
Fix: role="alert" on the error paragraph, and an aria-live="polite" status line next to the preview that reads out "Émulation de <Nom> (<id>)" / "Aucune émulation active".
9. No input has a programmatically associated label — Medium · FORM-01
Where: widgets/src/components/admin/AdminPanel.vue:11-20
What: Four controls, four <h2> elements acting as labels, zero associations. The PersonID <input> at :12 has no id, no <label for>, no aria-label — its only accessible name is placeholder="PersonID", which disappears on input. The three Selectbox instances (:15, :18, :20) pass no inputId/aria-label down to BccSelect (components/Selectbox.vue:2-11), so their accessible name depends entirely on whatever the library renders.
Also FORM-11: the PersonID field — the single purpose of the page — is not focused on mount, and there is no clear/reset control on it, both of which the forms/search-field anatomy calls for.
Why it matters: A screen-reader operator hears "edit text, blank" for the field that decides whose account they are about to open.
Fix: Real <label for> elements (demoting the <h2>s), and an inputId / aria-label prop threaded through Selectbox to the underlying BccSelect.
10. No <h1>, no landmarks, headings used as field labels — Medium · A11Y-04
Where: widgets/src/components/admin/AdminPanel.vue:2-30 · widgets/src/App.vue:2-5
What: The page's outermost elements are plain <div>s — no <main>, no <aside>/<nav> for the control column. Heading level 1 is never used; there are five <h2>s, of which four are field labels rather than section headings, so the heading outline reads as five sibling sections with no page title.
Why it matters: Heading and landmark navigation, the primary way a screen-reader user orients on an unfamiliar page, returns a list of field names.
Fix: <h1>Émulation widgets</h1> once, wrap the control column in <aside> and the preview in <main>, and convert the four label <h2>s to <label> (finding 9).
11. A raw HTTP status reaches the operator — Low · MSG-02
Where: widgets/src/components/admin/AdminPanel.vue:97
What: 404 and 403 are mapped to French sentences; everything else falls through to `Erreur ${res.status}`. A 500 or a 502 from the proxy renders as "Erreur 500".
This is the panel's own copy of the shape already recorded project-wide for widgets/src/api.ts in PROJECT-LEVEL.md — noted here only because it is a separate literal in a separate file, not re-filed as a project finding.
Why it matters: "Erreur 500" tells a non-technical volunteer administrator nothing and suggests no next step (MSG-03).
Fix: One generic fallback sentence with an action — "Erreur inattendue, réessaie dans un instant ou préviens un administrateur." Keep the status in console.error.
12. Mixed-language and implementation-flavoured labels — Low · CONTENT-lang (proposed)
Where: widgets/src/components/admin/AdminPanel.vue:11,17,19,110-117
What: In an all-French UI (CLAUDE.md: "Language is French throughout"), the panel labels a field « Year » (:19), lists a component as "Dons Progress" and "Event" (:114,116) alongside "Décharges" and "Camps", and titles the member field « Membre (PersonID) » — exposing the database column name as the label. MyEvent.vue:4 also titles the widget "Event" in French context.
Why it matters: Small, but this is the tool volunteers use to answer member questions, and the queue notes the audience is a mix of tech-savvy and non-tech-savvy staff.
Fix: « Année », « Progression des dons », « Événement », and « Membre » with the id format as helper text rather than in the label.
13. The Event preview is pinned to one hardcoded event that the component ignores — Low · FORM-inert (proposed)
Where: widgets/src/components/admin/AdminPanel.vue:27 (eventid="794e4a83-b8b6-4412-8627-f3c4abff7fcc") · widgets/src/widgets/my-events/components/MyEvent.vue:29
What: A literal event UUID from some environment is passed to every previewed component. MyEvent declares eventid as a required prop and then never reads it — the component lists all events for the sub-org. So the attribute is dead, and the panel offers no way to preview a specific event even though my-event is the widget most often embedded per-event.
Why it matters: The UUID will not exist in another environment or organisation, and it reads as configuration that does something when it does not. An operator debugging one event's widget cannot target that event.
Fix: Drop the prop from MyEvent if unused, or add an event selector to the panel that populates it from /api/widget/evenements.
Unverified
- A11Y-01 (contrast) —
text-neutral-600(:7),text-neutral-500(Selectbox.vue:15) andtext-red-600(:21) onbg-white, plus white onbg-neutral-900for the login link (:8). Needs computed values from a rendered page. Note thattext-red-600is also a raw Tailwind colour whereCLAUDE.mdmandates the semanticerror-fg/error-bgtokens — that part is verified from the guide, the contrast is not. - A11Y-06 (short viewport / responsive) — the control column is
w-screen sm:w-72inside aw-fullflex row (:2-3).w-screenignoring the scrollbar width inside aflex-rowparent is a common horizontal-overflow smell, andsm:h-screenon the column against anoverflow-autopreview (:27) needs a rendered viewport to judge. Not asserted. BccSelectinternals —node_modulesis not installed (see the standing caveat in PROJECT-LEVEL.md), so whetherBccSelectrenders a native<select>, exposes an accessible name without an explicit label, and is keyboard-operable could not be read from source. Finding 9 is written against whatSelectbox.vuepasses down, which is verifiable; the downstream behaviour is not.- Whether PersonID
25107(finding 4) corresponds to a real member in production — asserted only that it is a hardcoded literal id that is minted against on load.
Rules checked and passing (recorded so they are not re-checked): NAV-01 (no timed redirects), NAV-03 (widgets/index.html:7 sets a distinct, meaningful title — this bundle is routerless, so the project-level NAV-03 gap does not apply here), NAV-04 (no token or id in the browser URL; the token is a fetch response body), FORM-10 (paste not blocked), SEC-02 (the widget token is verified in widget-session.ts:34 before any session is issued, and the mint endpoint sits behind SessionGuard + OrgGuard + AdminGuard with an organisation-isolation check at org-admin.controller.ts:53), MSG-04 (the needsLogin dead end offers a working login link, and auth.controller.ts:37,60 honours the same-origin redirect so the operator returns to /widgets). CONTENT-01: not-applicable — see project-level i18n finding. SEC-01: not-applicable — "Membre inconnu dans cette organisation." does disclose existence, but only to an authenticated admin of that organisation, for whom member lookup is the tool's purpose; SEC-01 governs unauthenticated auth responses.
Baseline additions
Proposed with descriptive IDs per the brief; the orchestrator should renumber. The EMU-* family has no equivalent in BASELINE.md because no other project in the programme has an act-as-another-user surface — the same argument that produced the LIVE-* family for playout.
- EMU-01 — resolved-identity confirmation. A surface that acts on behalf of another user identifies that user by a human-readable name, not only by an internal id, and confirms the resolution before any session is created.
- EMU-02 — subject change resets subject state. When the subject of a view changes (the user being emulated, the account being inspected), every store, cache and token scoped to the previous subject is cleared. Remounting a component does not reset stores held at app scope.
- EMU-03 — no unrequested impersonation. An emulation surface starts empty. It never opens a session against a default or hardcoded subject on load.
- EMU-04 — persistent emulation chrome and a clean exit. While emulating, a persistent indicator naming the emulated identity is visible adjacent to the emulated content at every breakpoint, and an explicit exit control ends the session and clears its state.
- EMU-05 — emulated actions are attributable. A session created on behalf of another user carries the acting operator's identity, writes made under it are distinguishable from the user's own, and the fact of the emulation is recorded durably. (Overlaps SEC-04; could be folded in as SEC-04's impersonation clause instead.)
- FORM-inert — inert controls. A visible control whose value never reaches the request it appears to affect is a defect. Collides with the existing FORM-12(c) proposal in PROJECT-LEVEL.md — same rule, merge them.
- CONTENT-lang — language consistency in single-locale projects. In a project with no i18n layer, all user-facing copy is in the declared language; untranslated implementation labels ("Year", "Dons Progress") and raw database column names are copy defects even though CONTENT-01 does not apply.
Cross-project note
- EMU-* — no equivalent act-as-another-user surface is known in playout, customer-portal or tt-time-tracker, so this family is members-only until one turns up. Worth a grep for "impersonat"/"emulat"/"act as" in the other three before the family is added to BASELINE.md; if none exists, keep it as a members-scoped section.
- EMU-02 (stale shared store on subject change) generalises well beyond emulation: any project holding server state in an app-scoped Pinia store and swapping the subject with a
:keyhas the same bug. tt-time-tracker's admin screens (per-user timesheets) and playout's tenant chooser are the obvious places to check — the tenant chooser is already known to swap subject. - FORM-inert is already reported in another project's draft (FORM-12(c) in PROJECT-LEVEL.md), so at least two of four.
- MSG-01 (no
role="alert") and MSG-02 (raw status/SDK strings) are already confirmed cross-project themes; findings 8 and 11 are further instances, not new shapes. - Finding 7's shape — a rejected
onMountedpromise leaving a widget in its initial state so a failure renders as an empty state — is the members instance of the MSG-06 family already tracked for all four projects.