Skip to content

[UX] members — My décharges (parent liability waivers for minors) ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch: develop @ 2c22f5a · Files reviewed: 15 Patterns: forms/signature-pad, forms/form-validation

Summary ​

The waiver flow is well-built on its happy path: the clause the parent reads on screen (WaiverModal.vue:104-107) is word-for-word the clause archived in the signed PDF (decharge-pdf.service.ts:246-249), the server re-verifies family scope on every call, and it records IP, user-agent and a Europe/Paris timestamp. So what the parent agrees to is honest. The problems are around it: every failure path is silent or invisible (a failed widget login leaves a skeleton spinning forever, and "Voir" silently does nothing when a popup blocker eats window.open), and the two authorisation checkboxes are captured ambiguously — left unticked they are archived in the PDF as an explicit refusal of emergency medical care, with nothing on screen saying so and no way to correct it afterwards. That last point is the one to fix first: it is the only defect here that produces a wrong legal record rather than a bad experience.

Findings ​

1. Unticked authorisations are archived as refusals; signing is irreversible and never says so — High · MSG-05 ​

Where: widgets/src/widgets/my-events/components/WaiverModal.vue:85-96, 109-114; api/src/decharge/decharge-pdf.service.ts:221-230; api/src/decharge/decharge.service.ts:190-192What: Three separate consent problems in one screen.

  • autorisationSoins (emergency medical care) and autorisationImage (photo/video use) default to false and are not part of canSubmit (WaiverModal.vue:183-189). A parent can therefore sign without touching them. The PDF then renders [ ] J'autorise les soins medicaux d'urgence si necessaire. — an unticked box in an archived legal document reads as a deliberate refusal, but in the UI it is indistinguishable from "didn't notice". Nothing on screen states the consequence of leaving it unticked.
  • Nothing tells the parent the action is final. submitWaiverForMember rejects any second attempt with 409 (decharge.service.ts:190), so there is genuinely no correction path for a wrong "Lien de parenté", a wrong emergency contact or wrong medical information — and the history list offers only "Voir" and "Télécharger", no "corriger" and no contact route.
  • The parent is never told that their IP address and user agent are recorded (decharge.service.ts:366-367), although those are exactly the fields that make the signature evidential. Why it matters: A child arrives at a camp with a waiver on file that says their parent refused emergency medical treatment, because the parent skimmed a checkbox they were never told was consequential. Neither parent nor organiser can tell whether that was a decision or an oversight, and it cannot be corrected. Fix: Make both authorisations explicit two-option choices (Oui / Non, no default) and add them to canSubmit, so an unanswered question blocks submission rather than silently recording "non". State the consequence next to the emergency-care question. Add one line above the submit button — "Une fois signée, la décharge ne peut plus être modifiée ; contactez <adresse> en cas d'erreur." — and repeat the contact route on the confirmation screen and on signed rows in the history list. Mention the timestamp/IP recording in the consent block. (Minor related nit for the same fix: the archived PDF strips all accents — "Decharge de responsabilite", "soins medicaux d'urgence" — while the screen shows correct French, so the permanent record reads worse than the thing the parent approved.)

2. A failed widget login leaves the page spinning forever, with no error and no retry — High · MSG-04 ​

Where: widgets/src/widgets/my-decharges/components/MyDecharges.vue:173-180; widgets/src/stores/session.store.ts:27-30What: onMounted does await session.loginApi(...) outside any try. loginApi has no error handling of its own and widgetLogin throws on any non-2xx (api.ts:17). If the host page passes a stale/absent token, or the API is down, or the network drops, the promise rejects, the rest of onMounted never runs, and loading — initialised true at line 99 — is never set false. The widget renders <BccSkeleton> (line 6) permanently, and the only trace is an unhandled rejection in the console. The sibling branch has the same shape: when session.username is empty the widget sets loading = false and renders "Aucune décharge en attente / Aucune décharge signée" (lines 13, 37) — i.e. an auth failure is presented to the parent as "you have nothing to sign". Why it matters: A parent who owes a signature before a camp deadline sees either a permanent grey placeholder or a confident "nothing pending". Both are wrong, neither offers a retry, and the second actively tells them not to act. Fix: Wrap the mount sequence in try/catch/finally, always clear loading, and render a real error state with a "Réessayer" button that re-runs the mount sequence. Never render the empty-state copy when the session never authenticated — distinguish "loaded, nothing pending" from "could not load". Secondary (CONTENT-04): the skeleton names nothing being waited on.

3. "Voir" silently does nothing when the popup blocker intercepts it — High · MSG-06 (proposed) ​

Where: widgets/src/widgets/my-decharges/components/MyDecharges.vue:139-151What: viewPdf awaits decharges.pdfUrl(...) — a fetch — and only then calls window.open(url, "_blank") (line 143). The await breaks the user-gesture chain, so Safari and Firefox (and Chrome under stricter settings) block the popup. window.open's return value is not checked, so nothing is shown: no tab, no error, no fallback. The button also re-enables itself in the finally, so the UI returns to its resting state as though it had worked. Why it matters: On the browsers most French parents use on a phone, tapping "Voir" on a signed waiver appears to do nothing at all, repeatedly, with no explanation. "Télécharger" is unaffected because it uses a synthetic <a> click. Fix: Open the tab synchronously in the click handler (const w = window.open("", "_blank")) and set w.location = url after the fetch resolves; or drop window.open and reuse the <a download> path with target="_blank". Either way, check for null and surface a message with a fallback link.

4. The confirmation promises an email that is sent fire-and-forget — Medium · CONTENT-03 ​

Where: widgets/src/widgets/my-events/components/WaiverModal.vue:22-24; api/src/decharge/decharge.service.ts:394-404What: The success panel states unconditionally "Une copie du document signé vous a été envoyée par email." The server's send is wrapped in try { … } catch (error) { this.logger.error(…) } — a failure is logged and swallowed, and the endpoint still returns 200. There is also no validation of the parent's email beyond .trim() being non-empty (WaiverModal.vue:183-189; type="email" does nothing here because the fields are not inside a <form>), so a typo produces a silent bounce plus a confident on-screen claim. Why it matters: The PDF footer instructs "conservez-en une copie", and the parent believes they have one. When it never arrives they have no copy and no reason to look for it. Both this repo's own PDF and the email tell them to keep a copy they may not have. Fix: Return the send outcome from notifyConfirmation and word the confirmation accordingly ("Une copie a été envoyée à <email>" vs. "Nous n'avons pas pu envoyer l'email — téléchargez le document ici"). Always offer a direct download/view link in the success panel regardless of the email. Validate the email format before enabling submit.

5. No error or success in the flow is announced to assistive technology — Medium · MSG-01 ​

Where: WaiverModal.vue:15-17 (load error), 19-25 (success), 117-119 (submit error); MyDecharges.vue:72 (list error) What: Every one of these is a plain <div> swapped in by v-if. None carries role="alert", role="status" or aria-live. The reference implementation in forms/form-validation puts a role="status" aria-live="polite" element in the form for exactly this. The success state additionally replaces the entire form body, so a screen-reader user gets no notification that signing succeeded. Why it matters: WCAG 4.1.3. A blind parent presses "Signer la décharge" and hears nothing — neither the rejection reason nor the confirmation. Fix: role="alert" on the two error blocks and on the list error; role="status" on the success panel; move focus to the success heading after a successful submit.

6. Raw transport errors are shown to the parent, and none say what to do next — Medium · MSG-02, MSG-03 ​

Where: widgets/src/api.ts:32, api.ts:51; surfaced at MyDecharges.vue:124, 147, 163 and WaiverModal.vue:217, 246What: request() falls back to `HTTP ${response.status}` and apiBlobUrl() throws only that string. Every caller does e instanceof Error ? e.message : <fallback>, so the fallback is almost never reached and the user sees the raw string instead. Concretely: a signed waiver whose stored PDF is missing renders the text "HTTP 404" under the list; a dropped connection renders the browser's own "Failed to fetch" / "NetworkError when attempting to fetch resource". None of these strings say what to do next. Fix: Map status codes to human French sentences in api.ts (or in a small helper the widgets share), keep the raw string for the console only, and append an action — "Réessayez dans quelques instants" / "Contactez <adresse> si le problème persiste".

7. Required fields are unmarked and the submit button is disabled with no explanation — Medium · FORM-04, FORM-05, FORM-06 ​

Where: WaiverModal.vue:49-78, 130-135, 183-189What: canSubmit requires Nom, Email, Lien de parenté, a signature and the consent tick, but nothing on screen says so — no asterisks, no "obligatoire", no per-field messages. The two genuinely optional fields are labelled "(optionnel)" (lines 72, 76), which by contrast implies "Téléphone" is required — it is not. The submit button is :disabled="!canSubmit || submitting", so a parent who has missed the consent tick or whose drawn signature registered as empty sees a dead grey button and no way to find out why (FORM-05). It is also disabled during submission (FORM-06), which drops keyboard focus to <body> at the moment of the most important action in the flow. Why it matters: The most common failure — signing on a phone, missing the consent checkbox below the fold — presents as a broken button rather than as a correctable mistake. Fix: Mark required fields; keep the submit enabled and, on click, validate and show a summary of what is missing (with focus moved to the first offender); replace the submitting-disabled state with aria-busy="true" plus a re-entrancy guard in onSubmit (the guard at line 230 already exists).

8. An accidental tap outside the modal discards the whole waiver, signature included — Medium · FORM-12 (proposed) ​

Where: WaiverModal.vue:6-7What: The dialog is :dismissable-mask="!submitting" and :closable="!submitting", so a mask click or Esc closes it. @hide emits close, the parent flips waiverModalOpen to false, and the next open runs load() → resetForm() (lines 201-208), wiping every field, the emergency contact, the medical notes and the drawn signature. Nothing warns, and there is no draft persistence. Why it matters: This is a long form on a small screen — a drawn signature plus free-text medical information. A mistap on the backdrop while scrolling throws all of it away, and the parent has to redraw the signature. Fix: Set dismissable-mask="false", and when the form is dirty intercept close with a confirm ("Abandonner la décharge en cours ?"). Better still, preserve the entered values across close/reopen for the same inscriptionId rather than resetting unconditionally.

9. The signature control has no label, no accessible name, and no focus indicator — Medium · FORM-01, A11Y-03, A11Y-05 ​

Where: widgets/src/components/SignatureCanvas.vue:22-30, 32-42, 3-20, 44-49What: Four defects in one component, all in the control that produces the legally operative artefact.

  • The "Taper" input's only label is its placeholder="Tapez votre nom" (line 29) — FORM-01, and it disappears the moment the parent types.
  • That input sets focus:outline-none (line 26) with no replacement ring, so keyboard users lose the focus indicator entirely — WCAG 2.4.7.
  • The <canvas> (lines 32-42) has no role, no aria-label and no tabindex; to a screen reader it is an unnamed, uninteractive element. (The flow is still completable by keyboard because "Taper" is the default mode — this is a naming gap, not a keyboard trap.)
  • The helper text at line 45 is not linked to either control with aria-describedby; forms/signature-pad names "separating labels, hints and errors" as a top anti-pattern, and its reference markup pairs the canvas with a status span.
  • The "Taper"/"Dessiner" toggle (lines 4-19) conveys its selected state with background colour only — no aria-pressed, no role="radio"/aria-checked (proposed A11Y-07). Also, verifiable and visible to everyone: the typed signature is rendered in 'Dancing Script', 'Archivo', cursive (line 83), but the widgets bundle imports only Archivo (widgets/src/assets/tailwind.css:1). The admin copy of this same component does load Dancing Script (admin/src/client/assets/index.css:1). So in the member-facing widget the signature — both on screen and inside the stored PNG — renders in a fallback face, not the script font it was designed for. Fix: Add a real <label> for the typed input and an aria-label + aria-describedby for the canvas; restore a visible focus ring; give the mode toggle role="radiogroup" with aria-checked; add the Dancing Script import to widgets/src/assets/tailwind.css (and consider deduplicating the two SignatureCanvas.vue copies).

10. The modal's section titles are styled divs, not headings — Medium · A11Y-04 ​

Where: WaiverModal.vue:47, 84, 101What: "Représentant légal", "Autorisations" and "Signature" are <div class="text-sm font-semibold">. The list view outside the modal does this correctly (MyDecharges.vue:11, 35 use <h3>), so the inconsistency is within one feature. The grouped fields are also not <fieldset>/<legend>. Why it matters: A screen-reader user cannot navigate the waiver by section and gets a flat run of inputs with no structure — in a document they are being asked to accept legal responsibility for. Fix: Use <h3> (the dialog header is the h2-equivalent here) or <fieldset><legend> for each group.

11. "Décharge déjà signée" is a dead end — Medium · MSG-04 ​

Where: WaiverModal.vue:15-17, 130-131; api/src/decharge/decharge.service.ts:166-168What: If the waiver was already signed — a second tab, a stale list, both parents acting at once — getWaiverForMember returns 409 "Cette décharge a déjà été signée". The modal renders that as red centred text; the submit button is hidden (v-if="… && !loadError") leaving only "Annuler", and closing returns the parent to a list that still shows the item under "À signer" because nothing refreshes it. The 409 carries a machine-readable code: "ALREADY_SUBMITTED" (decharge.types.ts) that the client ignores. Why it matters: The parent is told they cannot sign, then returned to a screen that still says they must — a loop with no way out but a full page reload. Fix: Treat ALREADY_SUBMITTED as a success-adjacent state: show "Cette décharge a déjà été signée" with a "Voir le document" action, and refresh the overview on close so the item moves to "Historique".

12. "Effacer" is below the minimum target size — Low · A11Y-02 ​

Where: widgets/src/components/SignatureCanvas.vue:50-57What: The clear control is a text-xs (12px) underlined button with no padding classes inside a text-xs row, giving roughly a 16px-tall hit area — under the 24×24 CSS px floor of WCAG 2.5.8. It is also the recovery action for a mis-drawn signature on a touch screen, i.e. the one people most need to hit. Fix: Add px-2 py-1 (or min-h-6) and bump to text-sm.

13. Blob-URL timers are not cleaned up, and revoke after 60s while the tab may still need them — Low · NAV-02 ​

Where: MyDecharges.vue:145, 161What: Both PDF paths schedule setTimeout(() => URL.revokeObjectURL(url), 60_000) with no handle kept and no onUnmounted cleanup, so the timers survive the component. More visibly: the "Voir" tab holds a blob URL that stops resolving after a minute — reloading that tab, or restoring it later, yields a browser error page rather than the waiver. Fix: Track the handles and clear them in onUnmounted; revoke on the load event of the opened document, or serve the PDF from an authenticated URL the tab can re-request.

Unverified ​

  • A11Y-01 (contrast). Not checkable from code. Worth measuring: the pending cards use border-amber-300 bg-amber-50 with text-secondary/text-xs subtitles (MyDecharges.vue:20-27), and the helper/muted text in SignatureCanvas.vue is text-gray-500 on white at 12px.
  • A11Y-06 (short viewport / mobile keyboard). Needs a rendered page. The waiver modal is a long scrolling form (min-w-70 max-w-140, ~10 controls plus an 800×200 canvas) with its submit button in a sticky dialog footer — the interaction most likely to break is drawing a signature on a phone with the keyboard open.
  • Semantic colour tokens. text-danger, text-tertiary, text-secondary and border-on-primary are used 48 times across widgets/src but are defined neither in widgets/src/assets/tailwind.css nor anywhere I could read — widgets/node_modules/ is not installed in this checkout, so I could not confirm @bcc-code/component-library-vue/theme.css defines them. If it does not, every error message in this feature renders in body colour. Worth one pnpm install and a grep before anyone acts on this.
  • FORM-11 (focus on mount). Whether BccDialog moves focus into the form, and where, depends on the PrimeVue wrapper — not readable here.
  • CONTENT-01. not-applicable — see project-level i18n finding.

Baseline additions ​

  • MSG-06 — Every failure path renders a visible message. A failure is never presented as an empty state, a permanent loading state, or nothing at all. (Findings 2 and 3: a rejected login leaves a skeleton spinning; a blocked window.open produces no feedback whatsoever.)
  • MSG-07 — A confirmation never asserts a side effect that was not verified. If a notification is sent fire-and-forget, the confirmation says what the user can do instead, and offers the artefact directly. (Finding 4.)
  • FORM-12 — A form holding user-entered data warns before discarding it — modal dismissal, backdrop click, Esc, or navigation. (Finding 8.)
  • A11Y-07 — The selected/pressed state of a toggle or segmented control is exposed programmatically (aria-pressed, or role="radio" + aria-checked), not by colour alone. (Finding 9.)
  • SEC-06 — A consent captured for a distinct purpose (image rights, medical authorisation, marketing) is recorded as an explicit affirmative choice, separable from the primary action, never as an unticked default; and the user is told what is recorded alongside it (timestamp, IP) and how to withdraw it. (Finding 1. This is the rule the feature most needs and the one the baseline currently has no home for — SEC covers auth behaviour only.)

Cross-project note ​

  • Finding 3 (window.open after an await) is a hypothesis for two other repos with document-opening flows: tt-time-tracker/services/client/src/components/Modals/ModalInvoiceReview.vue and customer-portal's OrderDetailPage.vue, OrdersPage.vue, ServicePlansPage.vue. playout has no window.open at all. Worth checking whether each call is inside the original gesture. Not verified in those repos.
  • Finding 6 (raw HTTP <status> strings reaching users) comes from widgets/src/api.ts, which every members widget shares — so all six widgets in this repo inherit it, not just this feature. Any project whose fetch wrapper falls back to a status string will do the same.
  • Finding 5 (no role="alert"/role="status") was already the pattern in the playout password-reset audit that seeded MSG-01; expect it in all four.
  • Finding 1 is specific to members — no other project in the programme captures legal consent on behalf of a third party. The proposed SEC-06 generalises, though: customer-portal captures marketing/terms consent at signup, and the same "unticked default = recorded refusal" ambiguity applies.