CI / Tests (PHP 8.1) (pull_request) Successful in 1m0s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m0s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 3m8s
CI / Build Plugin Zip (pull_request) Skipped
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
Five items from the latest demo pass: - A policy's title can be edited from the Policies screen. Only the title moves; the slug is what the gates resolve policies by, so a rename can never detach a policy from acceptances already recorded against it. - Signup is one page again. The studio's registration questions move from a second step behind "Next" onto the main form, in an "About you" panel above the students being added, and that panel also asks an adult student for their birth year (the same us_birth_year meta a child's uses). register.js disables and hides the whole panel for a pure guardian, since the questions describe a student. - The password is re-scored on submit, not only as it is typed. zxcvbn's dictionary arrives after page load, so a password typed straight away was never scored at all and the first the student heard of it was the server rejecting the whole form. - Group-class sessions appear alongside lessons wherever upcoming lessons are listed: the [us_scheduler] panel (students and instructors) and the admin student detail page. GroupClass\SessionSchedule derives them from Offering::sessionWindows(), the same derivation the billing scan uses. They carry kind = 'group_class' and no Cancel action - a session is one date in a term, not a booked slot. - Deleting a user releases what the account was holding: each upcoming lesson is cancelled, its slot freed for rebooking, its pending payment voided, and active class enrolments cancelled. Past lessons and paid history are left alone. Tests: composer test (851), composer lint, composer cs all pass. Co-Authored-By: Claude Opus 5 <[email protected]>
225 lines
19 KiB
Markdown
225 lines
19 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) |
|
|
|
|
## Email and password validation
|
|
|
|
Both are checked on the server on every signup path, and the browser is given a
|
|
matching but *stricter* job so a bad password is caught before submitting.
|
|
|
|
**Email** — `type="email"` and `required` in the markup, `is_email()` on the
|
|
server, then `email_exists()` for "an account already exists for this email". A
|
|
personal invite fixes the address and the server always uses the invite's own
|
|
value, so a tampered field is ignored rather than validated.
|
|
|
|
**Password** — `Auth\PasswordPolicy` is the authority. It deliberately does
|
|
*not* try to reproduce a strength score in PHP; it rejects the categorically
|
|
bad, which is what a server can check without shipping a dictionary:
|
|
|
|
- shorter than `PasswordPolicy::MIN_LENGTH` (8 — NIST SP 800-63B's floor;
|
|
composition rules like "must contain a symbol" are deliberately **not** used,
|
|
as they push people towards predictable substitutions),
|
|
- one of the well-known leaked passwords,
|
|
- built from fewer than four distinct characters (`aaaaaaaa`, `abababab`),
|
|
- containing the user's own display name, email, or the part before the `@`.
|
|
|
|
The nuance happens in the browser. `register.js` scores the password with
|
|
zxcvbn through WordPress's own `password-strength-meter` script and refuses to
|
|
submit below `PasswordPolicy::MIN_SCORE` (2 of 4 — "medium"; enough to stop a
|
|
guessable password without demanding a passphrase to book a piano lesson). The
|
|
thresholds reach JavaScript via `wp_localize_script()` from the same constants
|
|
the server enforces, so the two cannot drift apart.
|
|
|
|
The verdict is applied with `setCustomValidity()` on the password field rather
|
|
than by disabling a button: an invalid field stops the submit without the button
|
|
needing to know why. zxcvbn's dictionary loads asynchronously, so the gate stays
|
|
open until it arrives — the server is the check that always runs.
|
|
|
|
The password is also **re-scored on submit**, not only as it is typed. Native
|
|
validation has already run by the time the `submit` event fires, so a verdict
|
|
reached there stops the submit by hand (`preventDefault()` + `reportValidity()`).
|
|
Without that, a password typed in the second before the dictionary arrived was
|
|
never scored at all, and the first the person heard of it was the server
|
|
rejecting the whole form.
|
|
|
|
## Registration Questions
|
|
When the studio has configured **account-scope** registration questions
|
|
(**Offerings → Questions → "Account signup"**, see `registration-questions.md`), they
|
|
are asked on the main form in an **About you** panel — alongside the account
|
|
holder's birth year, above the students they are adding, and only when they are a
|
|
student themselves (`self` or `both`). 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.
|
|
|
|
The form is one page with one submit. The questions used to be a second step
|
|
behind a "Next" button; that put what the studio needs to know about an adult
|
|
student on a screen reached only after everything else, and the two-step gate is
|
|
what made a weak password reachable — it advanced on a `checkValidity()` that had
|
|
not yet scored anything.
|
|
|
|
## 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 _<page title>_"** 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 asks **"Who are you registering?"** — just myself, on behalf
|
|
of one or more students, or both — and the student-bearing choices reveal a
|
|
repeatable child block (name, birth year, 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`.
|