# 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. ## 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 | | `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`) | | `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` | | `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 | | `created_at` | DATETIME | Insertion time | | `accepted_at` | DATETIME | When accepted; NULL while pending | ## 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. ## 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. ## 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) - List pending invites (email + invited date); 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`. ## 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`