The registration page's already-logged-in branch returned a bare sentence with nowhere to go, leaving the visitor to find their own way to their account. The invited-student branch a few lines above already built exactly the link that was missing. Extract that into continueLink() and use it for both logged-in outcomes. There is deliberately still no wp_login_url() fallback: sending someone already signed in to the login screen is the same dead end with extra steps, so with no page configured there is no link. Both messages now carry the us-register-form wrapper and enqueue the plugin stylesheet, which the invite branch emitted markup for but never loaded. Closes #131
15 KiB
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_urlfilter points WordPress's "Register" links at the registration page.login_initaction redirects anyaction=registerrequest (GET and POST) to the registration page before any processing runs.registration_errorsfilter is a fail-safe that rejectsregister_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): thewp_authenticate_userfilter blocks login while the email is unconfirmed; theuser_has_capfilter withholdsbook_lessonwhileus_awaiting_approvalis set, so a confirmed-but-unapproved student only reaches the "awaiting approval" screen on the booking page. - Email confirmation (
Auth\EmailConfirmationHandlerontemplate_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(orexpired). On?us_confirmed=1the registration page replaces the form with the confirmation message plus a "Sign in to your account" link — the configured sign-in page (blockloginPageId/ shortcodelogin_page_idattribute), falling back to the WordPress login screen. Theexpirednotice 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\RegistrationMailersends 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)
- 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. - 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. - 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.
- On submit, the token is re-validated (hashed lookup); a
us_studentuser is created, the policy acceptances are recorded (accounttype), the invite is markedaccepted, and the user is logged in. The submission is processed ontemplate_redirect(RegistrationPage::maybeHandleSubmit()) before any page output sowp_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 insiderender(), which runs duringthe_content, sent the cookie after headers and left the student logged out on the next view.) If the invite carries anoffering_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 — seegroup-classes.md.
Flow (self-approval mode)
- Studio admin enables Studio Settings → Registration and selects the registration page (shared with invites,
us_registration_page_id). - Anyone opens
[us_student_register]; the form collects an editable email, display name, password, and the required signup policies. - On submit a
us_studentuser is created in the pending state (RegistrationStatus::markPending()), acceptances are recorded (accounttype), a confirmation email is sent, and the user is not logged in. - The applicant opens the emailed
?us_confirm=<token>link → email confirmed, studio admins notified. - 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)
- 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.
- 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.
- On submit the account is created pending with the auto-approve marker (
RegistrationStatus::markPending($userId, autoApprove: true), metaus_auto_approve) and a confirmation email is sent. The invite row is not marked accepted — the link remains usable by others. - 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. - 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 theus_registration_page_idoption; 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. Ininvitemode: shows the form for a valid pending invite, else an "by invitation only" message. Inself_approvalmode: 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 attributeinvite_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 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. 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, viaBlockRegistrar::maybeAutoRedirect()ontemplate_redirect. It fires only on those two finished states (RegistrationPage::isRegistrationComplete()), so the "check your email" step, a validation error, and anexpiredconfirmation link are always shown rather than redirected past. With no page chosen nothing happens — there is deliberately no login-screen fallback for the redirect. Seeeditor-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 theuser_has_capfilter). Added toRoleManager::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.phptests/Unit/Auth/InviteRepositoryTest.phptests/Unit/Auth/RegistrationStatusTest.phptests/Unit/Auth/RegistrationLoginGateTest.phptests/Unit/Auth/EmailConfirmationHandlerTest.phptests/Unit/Auth/RegistrationPageTest.phptests/Unit/Auth/RegistrationApprovalControllerTest.phptests/Unit/Auth/RegistrationMailerTest.phptests/Unit/Payment/StudioSettingsTest.php