---
title: Add the access request form to the register page
type: task
status: completed
description: >-
  The user-facing half of the access-request feature: make the invite code optional on the register
  form, capture a free-form request letter, and show a confirmation panel when a codeless
  registration is accepted. Spans four redux files because the register payload is whitelisted at
  the action creator.
base_uri: user:task/league/add-access-request-register-form.md
created_at: '2026-09-12T19:24:42.245Z'
entity_id: e7efe4f5-107a-433e-8804-efc2d2e33f6e
finished_at: '2026-09-12T21:47:54.277Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - blocked_by [[████████████████████-███████-██████-███████████]]
  - relates [[████████████████████-████-██████-████████]]
started_at: '2026-09-12T21:38:30.251Z'
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-13T01:32:47.127Z'
visibility_analyzed_at: '2026-11-05T23:48:06.745Z'
---

# Implementation Plan: Add The Access Request Form To The Register Page

## Overview

- Put the access-request capture on the register page: make the invite code optional, add a free-form request letter, and replace the form with a confirmation panel when a codeless registration is accepted.
- This is the user-facing half of the access-request feature. The server lane, the table and the operator script ship first in [[████████████████████-███████-██████-███████████]]; until this lands, the codeless registration path exists and is spec-covered but no visitor can reach it.
- Impact: today the register form refuses without an invite code and tells the visitor to ask on Discord (`app/views/pages/auth/auth.js:276`). After this, a stranger applies in the form and is told what happens next.
- Ordering: blocked by [[████████████████████-███████-██████-███████████]], which must land the 202 contract first. Also gated on [[████████████████████-████-██████-████████]] closing its open operator browser readings — that task is In Progress and owns this exact file.

## Background

The register and login views are one page toggled by a `menu` state value (`app/views/pages/auth/auth.js:93`, `:111`). The form is deliberately uncontrolled: fields are native inputs styled by `app/styles/prose-form.styl` rather than MUI (`:41`), and submit reads values off the DOM (`:129`).

The redux path from that form to the server crosses four files, and three of them filter or rewrite the payload:

- `app/core/app/actions.js:80` -- `register` destructures a FIXED key set (`email`, `username`, `password`, `leagueId`, `teamId`, `invite_code`). Any other key is dropped silently before the request is built.
- `app/core/app/sagas.js:80-84` -- `saveToken` runs on `REGISTER_FULFILLED` and `LOGIN_FULFILLED`, reading the token from STATE rather than from the action payload, then forking `init`.
- `app/core/app/reducer.js:206-210` -- the same two actions merge `token: payload.data.token` into app state. This is what `saveToken` then reads.
- `app/core/api/service.js:615` -- `api_request` treats any 2xx as success, so a 202 dispatches `REGISTER_FULFILLED` through the normal path with no transport change.

The page already has three early returns ahead of the form: a signed-in notice (`:182`), a dead-invite-link branch (`:213-218`), and a checking-validity branch (`:249`). All three landed with the invite-links work in league `6697603ee`.

### Assumptions

- `app/core/app/actions.js:80` drops unknown keys, so a new form field cannot reach the API without an edit there. Checked: read the destructure; it is a fixed parameter list, not a spread.
- `reducer.js` writes the token and `saveToken` reads it back from state, so branching only the saga leaves `undefined` in app state. Checked: read `reducer.js:206-210` against `sagas.js:80-84`.
- A 202 needs no transport work. Checked: `app/core/api/service.js:615` keys success on the 2xx class.
- The server lane returns `202` with a body carrying no token. UNCHECKED here by construction -- it is the deliverable of the predecessor task, and the first step below re-reads the shipped contract rather than trusting this sentence.

## Design

The form gains two fields and loses a requirement. The invite code input becomes optional with helper text; a `request_letter` textarea is added, always rendered and labelled as required when no code is present; a honeypot input is added alongside, following the shipped waitlist pattern.

Always-rendering the textarea is deliberate. Showing it conditionally on the invite-code input being empty would mean introducing reactive state into a form whose whole design is to read the DOM once on submit, and the conditional buys nothing a label cannot say.

The pending result is carried in app state rather than component state, because the reducer is already the thing that interprets `REGISTER_FULFILLED` and the saga already branches on what the reducer wrote. Adding a second, parallel channel for one boolean would split one decision across two mechanisms.

Bounds and the honeypot field name are imported from `libs-shared/account-access-request.mjs`, shipped by the predecessor task, so the form and the handler cannot disagree.

### Knowledge Base Impact

- `app/views/pages/auth/auth.js:276` carries shipped copy asserting that registration needs an invite code and that codes are requested on Discord. This change falsifies it, so the string is a required edit, not an optional one.
- `libs-shared/page-routes.mjs` -- check whether the register route's meta copy also states the invite-code requirement.
- No `text/` entity and no new tag. No scratch slug. No orchestrator.

## Notes

- Do not verify this under `yarn dev:live`. Per [[███████████████████████████████-█████████]], `dev:live` points the production-mode frontend at the LIVE API, so a registration submitted through it creates a real account and a real approval email. Use the throwaway-database recipe in that guideline's "Standing up a throwaway database for an undeployed WRITE" section.
- `NODE_ENV=test` turns ON openapi response validation, so a new operation may need an entry in `api/swagger/response-validation-holdout.json` in a throwaway worktree to unblock local work.

## Plan

### Wire the payload through redux

- [x] **Widen the register action's payload**
  - File: `app/core/app/actions.js`
  - Purpose: the fixed destructure at `:80` drops `request_letter` and the honeypot before the request is built, so every other step in this task is inert without it
  - Changes: add `request_letter` and the honeypot field to the destructure and the emitted payload
  - Verify: a unit assertion that `app_actions.register({ request_letter: 'x' })` emits `request_letter` in its payload -- this fails on the unmodified tree

- [x] **Branch the reducer on a tokenless fulfilment**
  - File: `app/core/app/reducer.js`
  - Purpose: `:206-210` merges `token: payload.data.token` unconditionally, which writes `undefined` for a pending registration and leaves the confirmation panel with no state to read
  - Changes: split `REGISTER_FULFILLED` from `LOGIN_FULFILLED`; on a response with no token, record the pending result instead of writing `token`
  - Verify: a reducer unit test asserting a tokenless `REGISTER_FULFILLED` leaves `token` unchanged and sets the pending flag, and that `LOGIN_FULFILLED` is unaffected

- [x] **Branch the saga on the same condition**
  - File: `app/core/app/sagas.js`
  - Purpose: `saveToken` at `:80-84` writes `localStorage` and forks `init` on every `REGISTER_FULFILLED`
  - Changes: skip the token write and the `init` fork when the registration came back pending
  - Verify: after a tokenless register in the browser, `localStorage.token` is absent and no `/me` request is issued -- read the network panel, not just the rendered page

### Register form

- [x] **Make the invite code optional and add the letter and honeypot fields**
  - File: `app/views/pages/auth/auth.js`
  - Purpose: capture the request at the point of registration
  - Changes: relabel the invite code input as optional; add an always-rendered `request_letter` textarea labelled as required when no code is given; add the honeypot input; import bounds and the honeypot field name from `libs-shared/account-access-request.mjs`; submit both new values from the existing DOM read at `:129`
  - Verify: against a throwaway database per the Notes, submit with a code and land signed in, submit without a code and without a letter and see the field error, submit without a code and with a letter and get a 202

- [x] **Render the pending confirmation panel**
  - File: `app/views/pages/auth/auth.js`
  - Purpose: tell the applicant the request was received, name the address the answer goes to, and say the site is browsable while they wait
  - Changes: add a fourth branch ahead of the form, ordered against the three existing early returns at `:182`, `:213-218` and `:249`; read the pending result from app state
  - Verify: the panel renders the submitted email address and appears with no page reload and no route change; confirm the three pre-existing early returns still take precedence where they should by driving a signed-in visit and a dead invite link

- [x] **Replace the shipped invite-code-required copy**
  - File: `app/views/pages/auth/auth.js`
  - Purpose: `:276` currently reads that registration needs an invite code and that contributors request one on Discord, which this feature falsifies
  - Changes: rewrite the string to describe the request path; check `:236` in the same pass
  - Verify: `grep -n 'Discord' app/views/pages/auth/auth.js` returns nothing in the register branch, and the rendered page carries no invite-code-required claim

- [x] **Check the route meta copy**
  - File: `libs-shared/page-routes.mjs`
  - Purpose: per-route SEO and social copy may repeat the invite-code requirement
  - Verify: read the register and invite entries; if neither states the requirement, record that and tick -- `(no verify beyond the read -- inspection step)`

### Testing and validation

- [x] **Capture the suite baseline**
  - Verify: record passing and failing counts from `yarn test:isolated` with the command and commit hash before the first edit. Never `yarn test` -- league `CLAUDE.md:53` warns the shared test database is dropped by every run and two concurrent runs destroy each other

- [x] **Add the redux unit coverage**
  - Files: the action and reducer tests named in the steps above
  - Verify: each new assertion fails when its change is reverted

- [x] **Walk the flow end to end against a throwaway database**
  - Purpose: the individual steps are verified in isolation but the handoff between them is not
  - Verify: per the recipe in [[███████████████████████████████-█████████]] -- pipe `db/schema.postgres.sql` into a fresh `league_test_<slug>`, run `db/fixtures/users.mjs`, start the dev server against a local `NODE_ENV=test` API, then register without a code, see the panel, fail a login, approve with `yarn access-requests`, and log in successfully

- [x] **Run the full suite against the baseline**
  - Verify: `yarn test:isolated` shows no failure absent from the recorded baseline
