CI / Tests (PHP 8.2) (pull_request) Successful in 49s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m0s
CI / Coding Standards (pull_request) Successful in 2m51s
CI / PHPStan (pull_request) Successful in 2m57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
CI / No Debug Code (pull_request) Successful in 2s
The Student Registration block's "After email confirmation" panel becomes "After registration": the page it selects is now where a newly registered student continues to, and a new autoRedirect toggle sends them there instead of showing the link. Only the two finished states qualify (RegistrationPage::isRegistrationComplete): an invited student who is now logged in, and a self-signup back from the emailed confirmation link. A validation error, an expired confirmation link, and the intermediate "check your email" step all stay on the page so their message is read. The invited-student success previously had no link at all; it gains a "Continue to your account" one. That path deliberately has no WordPress-login-screen fallback — pointing someone already signed in at the login screen helps nobody — so continueUrl() distinguishes "no page chosen" from "page chosen", and the redirect does nothing until one is picked. Closes #115 Co-Authored-By: Claude Opus 5 <[email protected]>
168 lines
15 KiB
Markdown
168 lines
15 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 your account" link an invited student sees on the spot (`?us_registered=invite`; no link at all with no page chosen, since an already-signed-in student has no use for the login screen).
|
|
- **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`
|