# 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=` 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 = `. ## 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=`); 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=` 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=` 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()`). ## 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`