Files
unsupervised-scheduler/docs/features/account-registration.md
T
KydoimosandClaude Opus 5 1847159e31 Fix five findings from a security assessment of the plugin
The assessment looked for three things: whether students can reach each
other's bookings, whether payment settings can be dodged, and whether the
plugin opens a way into the rest of the install. The student-isolation and
payment paths held up. These are what did not.

- The front-end login form told WordPress not to work out whether the site
  was secure, so on HTTPS every student's session cookie was issued without
  the Secure flag. wp_signon() only derives it from is_ssl() when the second
  argument is left at its default; an explicit false reads like "no
  preference" and is not.

- The update check took whatever download URL the release API returned and
  handed it to core, which unpacks it over the installed plugin. The package
  must now be https on git.unsupervised.ca exactly, compared on the parsed
  host so a lookalike name cannot pass.

- Uninstalling dropped 2 of 14 tables and left the Stripe secret and webhook
  signing key in wp_options. Removal is now a choice made in advance on
  Access -> Plugin removal: records are kept unless the owner opts in (with a
  typed confirmation), while credentials and the borrowed core registration
  settings go every time.

- Open registration switches on the site-wide users_can_register and makes
  Student the default role, arming any other signup form on the site to mint
  students who could book and be billed immediately. The pending state is now
  decided once, on user_register, rather than by whichever form created the
  account.

- Cancel and withdraw answered "not yours" differently from "does not exist",
  which let a signed-in student enumerate the studio's bookings. Both now
  give the same 404.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 11:55:53 -03:00

21 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_register1 (Settings → General "Anyone can register")
  • default_roleus_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.

Holding signups that came from somewhere else

Blocking core's own form is not the whole story. users_can_register=1 and default_role=us_student are site-wide settings, so they also arm every other route into wp_insert_user() a site happens to have — another plugin's signup form, a membership add-on. An account minted that way arrives holding book_lesson, with no email confirmed, no studio approval and no policy acceptance on file, and could book and be billed immediately.

So the pending state is not decided by whichever form created the account. It is decided once, on user_register, by Auth\RegistrationLoginGate::holdUnknownSignup():

New account Result
Not a us_student untouched — instructors and everyone else are not this feature's business
Created by someone holding manage_students (including an admin adding a user in wp-admin) left active — a deliberate act by someone who could approve it in the next click
Anything else held via RegistrationStatus::hold() and queued under Pending Students

A hold sets us_awaiting_approval=1 and us_email_confirmed=1. The account was never asked to confirm anything and has no token to answer with, so blocking its login would strand it; what the hold withholds is book_lesson, until a studio admin approves it.

The plugin's own paths all land in the last row and then say what they meant:

  • a self-signup calls RegistrationStatus::markPending(), which replaces the hold with a real, unconfirmed pending state (it clears us_email_confirmed explicitly for exactly this reason);
  • an invited student is approved outright by RegistrationPage before the auto-login — the invitation is the approval;
  • a guardian's child is approved outright by GuardianService::createChild(); the account is never signed in to, and queueing every child a family adds would be nonsense.

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.

Emailtype="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.

PasswordAuth\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.
  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, including hold()), Auth\RegistrationLoginGate (login gate, booking-cap gate, and the user_register hold), 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.