• Mahmoud Aglan's avatar
    feat(portal): self-registration behind a closed door, and the accessibility gate · 500806ca
    Mahmoud Aglan authored
    The last of the programme, plus the exit gate CLAUDE.md requires before any UI
    work counts as finished.
    
    Self-registration (W10) — shipped, and closed
    ---------------------------------------------
    This is the only path in the product that writes into `people` and
    `participants` with no member of staff in the loop, so it ships **off**:
    `portal.self_registration_enabled` defaults to false on every tenant including
    new ones, and the middleware answers 404 — never 403, because a 403 advertises
    that there is a signup form here and invites someone to look for the setting.
    A public form that starts accepting strangers because a deploy happened is not
    a decision anybody made.
    
    Phone verification is the precondition, not a feature. The flow it replaces was
    a study in how not to do this: `verify()` accepted the constant '0000' whenever
    a seeded setting said 'demo', then resolved *any* active user by phone —
    academy owners included — and minted a token with `mobile:*`; and in the other
    mode it generated a code, cached it, and never sent it anywhere, so turning the
    bypass off locked everyone out rather than securing anything.
    
    So: no bypass exists, in any mode, behind any flag. Only the SHA-256 is stored,
    with a bounded attempt count, in a table rather than the cache — a code you
    cannot audit is a code you cannot investigate. A send that fails deletes the
    record, because a stored code nobody received is precisely the old failure.
    There is a test asserting '0000' and '1234' are refused.
    
    Registration goes **through** ParticipantService rather than around it. Writing
    the row directly skipped the participant number, the already-a-member check,
    the audit columns and ParticipantRegistered — a second creation path that
    looked identical and was not. It does not enrol and it does not take money:
    EnrollmentService::enroll() needs an actor authorised to enrol and a
    self-registering guardian is not one. The member asks; staff enrol.
    
    `people.created_by` is NOT NULL and there is no staff member here, so the
    account is created first with no person attached and becomes the author of its
    own records — which is also the truth about who typed them. Loosening the
    column would have weakened it for every other path.
    
    DuplicateDetectionService runs on every signup and its findings are stored on
    the row and shown in the approval queue. It has existed for a long time with
    nothing surfacing what it found, so a second Person for an existing member
    appeared silently and the two drifted apart forever.
    
    The accessibility gate
    ----------------------
    Eleven checks against the HTML the portal actually renders for a real member,
    not against the templates: language matching direction, image alternatives,
    accessible names on every icon-only control, a label for every form control,
    named landmarks, focus never globally removed, reduced motion honoured, a
    stated focus ring, announced errors, and dir=ltr on numeric inputs.
    
    Two things it found. There was no explicit focus-visible style, so the ring was
    the browser default — a thin blue line that disappears against a tenant whose
    brand is blue; it is now `currentColor`, which inherits an already
    contrast-checked colour and is legible on every surface in both themes.
    And validation messages were rendered as plain text: a screen-reader user
    submitted a form and heard nothing. Every one is a live region now, asserted at
    the source, because an error block only renders when there is an error and a
    clean page proves nothing either way.
    
    Also: `:focus` gets scroll-margin so a focused control is never left under the
    sticky header or the bottom tab bar (2.4.11).
    
    Verification
    ------------
    - 145 migrations from zero on an empty database, seeded, booted a second time:
      every portal grant intact, self-registration absent and therefore closed
      (SettingsService returns the default, which is false — it fails closed).
    - The restored oc_sport tenant: nothing to migrate, seeders clean, health 200,
      713 invoices / 356 participants / 649 payments untouched.
    - Suite: 76 pass on SQLite; on the tenant PortalSmoke 3/3, AdminScreens 2/2,
      PaymentProof 11/11, CheckInScan 10/10, ServiceRequestEffect 14/14,
      SelfRegistration 13/13, PortalAccessibility 11/11.
    Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
    500806ca