Files
unsupervised-scheduler/docs/features/account-registration.md
T
thatguygriffandClaude Opus 5 b772e1811e Let parents register once and book for their children
A parent registers once and manages lessons for one or more children, who
need no login of their own. A child is a real wp_users row with the student
role but no usable login — so student_id keeps meaning "a WordPress user"
on every table, and booking, credits, policies and enrolments work unchanged.
A us_guardians link table maps guardian to child.

The signup form gains a parent/guardian tick that reveals a block per child,
with the account-signup questions asked per child rather than per guardian
— they describe the student, not the account holder. Signup policies are
recorded once per child with the guardian as the acceptor, which is the
record that actually means something. A family that half-creates is rolled
back entirely rather than leaving a guardian who cannot re-register.

The booking and enrolment forms gain a "Who is this for?" picker listing
children first, so the default selection is never the parent — booking for
the wrong child is correctable, quietly billing a parent for their kid's
lesson is not. POST /bookings and POST /enrollments take an optional
student_id honoured only for that child's guardian; anything else is a 403.
That check is the authorisation boundary of the feature.

Payments and credits gain a payer: the charge names the child it was for and
the guardian who owes it, so per-child reporting is unchanged while notices,
receipts and the payment step reach the parent. Credit is held by the payer,
so one child's cancellation can settle a sibling's charge, and the daily
billing scan sends a guardian one notice covering every child.

Closes #132

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 16:07:52 -03:00

177 lines
16 KiB
Markdown

# Feature: Account Registration
## Overview
People register for a student account through a front-end page, accepting any
signup-scoped policies at that time. Registration is **invite-only** by default: a
studio admin sends an invite, and the invitee completes signup via a tokenised
link. A studio can instead switch on **open (self-approval) registration**, where
anyone may sign up, confirm their email, and then be approved by a studio admin
before the account can be used. Both modes coexist — invites keep working when
open registration is on.
A studio admin can also generate a **group invite link** — a multi-use, tokenised
link with an explicit expiry date (e.g. for a newsletter). Anyone with the link
may register while it is valid, regardless of the registration mode: they supply
their own email, must confirm it, and are then **approved automatically**
group-link signups never enter the Pending Students queue.
## Registration Modes
Stored in the `us_registration_mode` option (default `invite`), toggled from
**Studio Settings → Registration**:
- `invite` — only a valid, pending invite token grants access to the registration form.
- `self_approval` — anyone may register on the registration page; each account is created in a pending state, must confirm its email, and is then approved (or rejected) by a studio admin.
### Enabling open registration
The Studio Settings toggle is the source of truth. Enabling it mirrors into the
two core WordPress options the flow relies on, and **snapshots** their previous
values (`us_registration_prev_can_register`, `us_registration_prev_default_role`):
- `users_can_register``1` (Settings → General "Anyone can register")
- `default_role``us_student`
Disabling restores the snapshot, so the toggle never permanently overwrites a
site's own membership settings. Only enable/disable *transitions* touch the core
options — saving unrelated settings leaves them alone.
See `Payment\StudioSettings::applyRegistrationMode()`.
### Blocking the native registration form
Because `users_can_register=1` also switches on WordPress's own
`wp-login.php?action=register` form — which cannot collect the required signup
policy acceptances — that form is blocked while open registration is on, so it can
never be used to create a policy-less account (`Auth\EmailConfirmationHandler`):
- `register_url` filter points WordPress's "Register" links at the registration page.
- `login_init` action redirects any `action=register` request (GET **and** POST) to the registration page before any processing runs.
- `registration_errors` filter is a fail-safe that rejects `register_new_user()` outright.
## Account Lifecycle (self-approval)
State lives entirely in user meta (`Auth\RegistrationStatus`). Only the raw
confirmation token's SHA-256 hash is stored; the token expires after 48h
(`EMAIL_CONFIRM_EXPIRY_HOURS`).
| State | User meta | Login | Booking |
|---|---|---|---|
| Email unconfirmed | `us_awaiting_approval=1`, `us_email_confirm_token`(hash) + `us_email_confirm_expires` set | blocked ("confirm your email") | — |
| Confirmed, awaiting approval | `us_awaiting_approval=1`, `us_email_confirmed=1`, token/expiry cleared | allowed | withheld → pending screen |
| Approved / active | `us_awaiting_approval` deleted, `us_email_confirmed=1` | allowed | full student |
| Rejected | account hard-deleted (`wp_delete_user`) | n/a | n/a |
| Invite/admin-created student | none of these metas | allowed | full student |
- **Login gate** (`Auth\RegistrationLoginGate`): the `wp_authenticate_user` filter blocks login while the email is unconfirmed; the `user_has_cap` filter withholds `book_lesson` while `us_awaiting_approval` is set, so a confirmed-but-unapproved student only reaches the "awaiting approval" screen on the booking page.
- **Email confirmation** (`Auth\EmailConfirmationHandler` on `template_redirect`): opening the emailed `?us_confirm=<token>` link confirms the email, notifies the studio admins, and redirects back to the registration page with `?us_confirmed=1` (or `expired`). On `?us_confirmed=1` the registration page replaces the form with the confirmation message plus a "Sign in to your account" link — the configured sign-in page (block `loginPageId` / shortcode `login_page_id` attribute), falling back to the WordPress login screen. The `expired` notice keeps the form.
- **Approval** (`Auth\RegistrationApprovalController`, **Students → Pending Students**, `manage_students`): approve clears the pending flags and emails the student; reject emails them and hard-deletes the account so the email is freed to re-apply.
- **Emails**: `Auth\RegistrationMailer` sends the confirmation link, the admin heads-up, and the approval/rejection notices.
## Data Model — `{prefix}us_invites`
| Column | Type | Notes |
|--------------------|------------------|--------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `email` | VARCHAR(191) | Invited email address; empty string for group links |
| `token` | VARCHAR(64) | SHA-256 hash of the token embedded in the registration link (raw token is never stored) |
| `role` | VARCHAR(32) | Role granted on acceptance (default `us_student`) |
| `kind` | VARCHAR(10) | `personal` (single-use, per email) or `group` (multi-use link) |
| `offering_id` | BIGINT UNSIGNED | Set when a personal invite is tied to an invite-only group class (see `group-classes.md`); NULL otherwise |
| `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` (group links stay `pending` until revoked/expired) |
| `invited_by` | BIGINT UNSIGNED | WordPress user ID of the studio admin who invited |
| `accepted_user_id` | BIGINT UNSIGNED | The created user's ID once accepted; NULL while pending / for group links |
| `created_at` | DATETIME | Insertion time |
| `accepted_at` | DATETIME | When accepted; NULL while pending / for group links |
| `expires_at` | DATETIME | Explicit expiry (end of the chosen day); set on every group link, NULL for personal invites (which expire 14 days after creation) |
## Registration Questions (signup step two)
When the studio has configured **account-scope** registration questions
(**Offerings → Questions → "Account signup"**, see `registration-questions.md`), the
registration form becomes two steps: name/email/password/policies first, then the required
questions. This applies to **every** signup path (invite, group link, self-approval).
Required answers are validated before the account is created, and are stored against the new
user (`us_question_answers`, `registration_type = 'account'`). A studio admin reviews them
under **Registration Information** on the student's admin screen.
## Policy Acceptance Scope
Policies declare **when** they must be accepted via `us_policies.acceptance_scope`:
`signup`, `booking`, or `both` (see `policies.md`). The registration form requires
acceptance of every published policy scoped `signup` or `both`. Acceptances are
recorded in `us_policy_acceptances` with `registration_type = account` and
`registration_id = <new user ID>`.
## Flow (invite mode)
1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created storing the token's SHA-256 hash, and the registration link (with the raw token) is shown **once** in a notice. To re-send a lost link, revoke and re-invite.
2. The invitee opens `[us_student_register]` with the token (`?us_invite=<token>`); the lookup hashes the submitted token and matches it against the stored hash.
3. The form shows the invited email **pre-filled and read-only** (the server always uses the invite's address on submit, so a tampered value is ignored) and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. A token that is no longer redeemable (expired / accepted / revoked) renders the normal editable email field instead when open registration is on.
4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. The submission is processed on `template_redirect` (`RegistrationPage::maybeHandleSubmit()`) **before** any page output so `wp_set_auth_cookie()` actually persists — it then post/redirect/gets back to the page with `?us_registered=invite`, where the now-logged-in student sees the "created and logged in" confirmation. (Processing the form inside `render()`, which runs during `the_content`, sent the cookie after headers and left the student logged out on the next view.) If the invite carries an `offering_id` (a group-class email invite), the new account is linked to the matching access grant so the invite-only class becomes enrollable for them — see `group-classes.md`.
## Flow (self-approval mode)
1. Studio admin enables **Studio Settings → Registration** and selects the registration page (shared with invites, `us_registration_page_id`).
2. Anyone opens `[us_student_register]`; the form collects an editable email, display name, password, and the required signup policies.
3. On submit a `us_student` user is created in the pending state (`RegistrationStatus::markPending()`), acceptances are recorded (`account` type), a confirmation email is sent, and the user is **not** logged in.
4. The applicant opens the emailed `?us_confirm=<token>` link → email confirmed, studio admins notified.
5. Studio admin approves under **Students → Pending Students** → pending flags cleared, student emailed; they can now log in and book. Rejection deletes the account.
## Flow (group invite link)
1. Studio admin opens **Invites** and generates a **group link**, choosing the expiry date (required; the link stops working at the end of that day). The link is shown **once**, like personal invite links.
2. Anyone opens the link while it is pending and unexpired — in **any** registration mode — and the form collects an **editable email**, display name, password, and the signup policies.
3. On submit the account is created pending with the auto-approve marker (`RegistrationStatus::markPending($userId, autoApprove: true)`, meta `us_auto_approve`) and a confirmation email is sent. The invite row is **not** marked accepted — the link remains usable by others.
4. Opening the `?us_confirm=<token>` link confirms the email and **approves the account immediately** (`EmailConfirmationHandler`): no admin heads-up, no Pending Students entry; the student gets the "approved" email and the page shows a "ready to use" notice (`?us_confirmed=ready`) with a sign-in link.
5. The link can be revoked at any time from the Invites page.
## Admin Interface
**Invites** in wp-admin (`manage_students`, studio admin only):
- Select the **registration page** (the page hosting `[us_student_register]`), stored in the `us_registration_page_id` option; invitation links point there (falling back to the home page if unset)
- Invite an email (creates a pending invite; the link is displayed once, at creation only)
- Generate a **group invite link** with a required expiry date (link displayed once)
- List pending invites (email or "Group link", created + expiry dates); revoke an invite
**Pending Students** — submenu under Students (`manage_students`), only relevant in `self_approval` mode:
- "Awaiting approval" (email confirmed) — approve or reject
- "Awaiting email confirmation" (not yet confirmed) — reject only
## Frontend Shortcode
- `[us_student_register]` — the registration page. In `invite` mode: shows the form for a valid pending invite, else an "by invitation only" message. In `self_approval` mode: shows the form to anyone (editable email), and renders confirmation-result notices from `?us_confirmed=1|expired`.
- The invitation-only message is customisable: block attribute `inviteOnlyMessage` (set under the block's **Invitation-only notice** panel) / shortcode attribute `invite_only_message`. Blank falls back to the default wording (`RegistrationPage::inviteOnlyMessage()`).
## Where Students Go Next
The block's **After registration** panel picks the page a student continues to once
registration finishes, and whether they get there by hand or automatically.
- **Sign-in page** (`loginPageId` / `login_page_id`) — the target of the "Sign in to your account" link shown after email confirmation (`?us_confirmed=1|ready`, falling back to the WordPress login screen) and of the **"Continue to _&lt;page title&gt;_"** link every **logged-in** visitor gets (`RegistrationPage::continueLink()`): an invited student who just finished signing up (`?us_registered=invite`), and anyone who simply arrives at the registration page already signed in. The link names the chosen page (via `get_the_title()`) so the visitor knows where it goes; an untitled page falls back to "Continue to your account" rather than reading "Continue to ". Neither gets the WordPress-login-screen fallback — with no page chosen there is no link at all, since sending someone already signed in to the login screen is the same dead end with extra steps.
- **Redirect automatically** (`autoRedirect`, block only) — sends the student to that page instead of showing the link, via `BlockRegistrar::maybeAutoRedirect()` on `template_redirect`. It fires only on those two finished states (`RegistrationPage::isRegistrationComplete()`), so the "check your email" step, a validation error, and an `expired` confirmation link are always shown rather than redirected past. With no page chosen nothing happens — there is deliberately no login-screen fallback for the redirect. See `editor-blocks.md`.
## Token Redirect
A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`)
sends any front-end request carrying a `us_invite` token to the configured
registration page (preserving the token), unless it is already on that page. This
covers invitation links generated/shared before a registration page was selected.
No-op when no registration page is set.
## Capabilities
- `manage_students` — manage invites and approve/reject pending students (studio admin; administrators inherit it via the `user_has_cap` filter). Added to `RoleManager::STUDIO_ADMIN_CAPS`.
## Implementation
- Models: `Unsupervised\Schedular\Auth\Invite`
- Repository: `Unsupervised\Schedular\Auth\InviteRepository`
- Admin controllers: `Unsupervised\Schedular\Auth\RegistrationController` (invites), `Unsupervised\Schedular\Auth\RegistrationApprovalController` (pending students)
- Frontend: `Unsupervised\Schedular\Auth\RegistrationPage`
- Self-approval flow: `Auth\RegistrationStatus` (lifecycle meta), `Auth\RegistrationLoginGate` (login + booking-cap gate), `Auth\EmailConfirmationHandler` (confirm link + native-form block), `Auth\RegistrationMailer` (emails)
- Settings toggle: `Payment\StudioSettings` (`us_registration_mode`, core-option mirror/restore)
- Reuses `Policy\PolicyRepository`, `Policy\PolicyVersionRepository`, `Policy\AcceptanceRepository`
- Schema: `us_invites`; `us_policies.acceptance_scope`. Self-approval adds no tables — state is WordPress user meta.
## Tests
- `tests/Unit/Auth/InviteTest.php`
- `tests/Unit/Auth/InviteRepositoryTest.php`
- `tests/Unit/Auth/RegistrationStatusTest.php`
- `tests/Unit/Auth/RegistrationLoginGateTest.php`
- `tests/Unit/Auth/EmailConfirmationHandlerTest.php`
- `tests/Unit/Auth/RegistrationPageTest.php`
- `tests/Unit/Auth/RegistrationApprovalControllerTest.php`
- `tests/Unit/Auth/RegistrationMailerTest.php`
- `tests/Unit/Payment/StudioSettingsTest.php`
## Parent/Guardian Signup
The registration form also offers **"I'm registering as a parent or guardian"**,
which reveals a repeatable child block (name, date of birth, and the
account-scope questions asked **per child**). Each child becomes a login-less
`us_student` user linked to the guardian, and the signup policies are recorded
once per child with the guardian as the acceptor. Available on every signup path
— personal invite, group link, and self-approval. See
`parent-guardian-accounts.md`.