From 73707559513ad5e3aa51eb3f16d3da972679feb2 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Sat, 18 Jul 2026 10:50:21 -0300 Subject: [PATCH] Add open student registration with email confirmation and approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Students could previously join by invite only. Add an optional self-approval mode, toggled from Studio Settings → Registration: anyone may sign up on the existing [us_student_register] page, confirm their email via a tokenised link, and then be approved by a studio admin before the account is usable. - Enabling the toggle mirrors WordPress's own membership settings (users_can_register + default_role = us_student) and snapshots their previous values so disabling restores them. - WordPress's native registration form is blocked while open registration is on (login_init redirect + registration_errors fail-safe + register_url) so it cannot bypass signup policy acceptance. - Pending accounts: unconfirmed email cannot log in; confirmed but unapproved can log in but the booking capability is withheld and the booking page shows an "awaiting approval" screen. - Approve/reject from Students → Pending Students; reject hard-deletes the account so the email is freed to re-apply. - Invite registration is unchanged; both modes coexist. Account lifecycle lives in user meta (RegistrationStatus); no new tables. Closes #63 Co-Authored-By: Claude Opus 4.8 --- README.md | 4 +- docs/features/account-registration.md | 80 ++++++++-- src/AdminMenu.php | 40 +++-- src/Auth/EmailConfirmationHandler.php | 128 ++++++++++++++++ src/Auth/RegistrationApprovalController.php | 102 +++++++++++++ src/Auth/RegistrationLoginGate.php | 61 ++++++++ src/Auth/RegistrationMailer.php | 113 ++++++++++++++ src/Auth/RegistrationPage.php | 92 +++++++++--- src/Auth/RegistrationStatus.php | 135 +++++++++++++++++ src/Booking/BookingPage.php | 5 + src/Payment/StudioSettings.php | 68 +++++++++ src/Plugin.php | 9 +- templates/admin/registrations.php | 92 ++++++++++++ templates/admin/settings.php | 22 +++ templates/frontend/register-page.php | 104 +++++++------ .../Auth/EmailConfirmationHandlerTest.php | 105 +++++++++++++ .../RegistrationApprovalControllerTest.php | 96 ++++++++++++ tests/Unit/Auth/RegistrationLoginGateTest.php | 118 +++++++++++++++ tests/Unit/Auth/RegistrationMailerTest.php | 84 +++++++++++ tests/Unit/Auth/RegistrationPageTest.php | 141 ++++++++++++++++++ tests/Unit/Auth/RegistrationStatusTest.php | 106 +++++++++++++ tests/Unit/Payment/StudioSettingsTest.php | 88 +++++++++++ tests/bootstrap.php | 8 + 23 files changed, 1713 insertions(+), 88 deletions(-) create mode 100644 src/Auth/EmailConfirmationHandler.php create mode 100644 src/Auth/RegistrationApprovalController.php create mode 100644 src/Auth/RegistrationLoginGate.php create mode 100644 src/Auth/RegistrationMailer.php create mode 100644 src/Auth/RegistrationStatus.php create mode 100644 templates/admin/registrations.php create mode 100644 tests/Unit/Auth/EmailConfirmationHandlerTest.php create mode 100644 tests/Unit/Auth/RegistrationApprovalControllerTest.php create mode 100644 tests/Unit/Auth/RegistrationLoginGateTest.php create mode 100644 tests/Unit/Auth/RegistrationMailerTest.php create mode 100644 tests/Unit/Auth/RegistrationPageTest.php create mode 100644 tests/Unit/Auth/RegistrationStatusTest.php create mode 100644 tests/Unit/Payment/StudioSettingsTest.php diff --git a/README.md b/README.md index 8a5bc3a..5e549f9 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ model, REST API, classes, and tests. For contributor/architecture guidance see | Availability (durations, weekly recurrence, calendar) | [availability-management.md](docs/features/availability-management.md) | ✅ Implemented | | Registration questions (per-offering intake) | [registration-questions.md](docs/features/registration-questions.md) | ✅ Implemented | | Policies (drafting, versioning, tracked acceptance) | [policies.md](docs/features/policies.md) | ✅ Implemented | -| Account registration (invite-only, signup policy acceptance) | [account-registration.md](docs/features/account-registration.md) | ✅ Implemented | +| Account registration (invite or open self-approval, email confirmation, signup policy acceptance) | [account-registration.md](docs/features/account-registration.md) | ✅ Implemented | | Lesson booking (offering → questions → policies) | [lesson-booking.md](docs/features/lesson-booking.md) | ✅ Implemented | | Group classes (capacity-enforced enrolment) | [group-classes.md](docs/features/group-classes.md) | ✅ Implemented | | Student administration (studio-admin view) | [student-administration.md](docs/features/student-administration.md) | ✅ Implemented | @@ -49,7 +49,7 @@ model, REST API, classes, and tests. For contributor/architecture guidance see | `[us_booking]` | Student calendar + private-lesson registration flow | | `[us_group_classes]` | Browse and enrol in group classes | | `[us_student_login]` | Front-end student login | -| `[us_student_register]` | Invite-based account registration (accepts signup policies) | +| `[us_student_register]` | Account registration — invite-based, or open self-signup with email confirmation + admin approval (accepts signup policies) | ## REST API diff --git a/docs/features/account-registration.md b/docs/features/account-registration.md index 6819ece..4a85306 100644 --- a/docs/features/account-registration.md +++ b/docs/features/account-registration.md @@ -4,13 +4,55 @@ 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 settings seam (`us_registration_mode`) allows switching to open -self-registration with approval later. +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`): -- `invite` — only a valid, pending invite token grants access to the registration form. *(implemented)* -- `self_approval` — anyone may register; the account is created in a pending state until a studio admin approves it. *(reserved for a later iteration)* +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`). +- **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` @@ -39,14 +81,25 @@ recorded in `us_policy_acceptances` with `registration_type = account` and 3. The form pre-fills the email and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. 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. Shows the form for a valid pending invite; otherwise shows an "by invitation only" message (in `invite` mode). +- `[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()`) @@ -56,16 +109,25 @@ covers invitation links generated/shared before a registration page was selected No-op when no registration page is set. ## Capabilities -- `manage_students` — manage invites (studio admin; administrators inherit it via the `user_has_cap` filter). Added to `RoleManager::STUDIO_ADMIN_CAPS`. +- `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 controller: `Unsupervised\Schedular\Auth\RegistrationController` +- 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` +- 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` diff --git a/src/AdminMenu.php b/src/AdminMenu.php index e5109fb..3907cf7 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -8,7 +8,9 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Auth\AccessSettings; use Unsupervised\Schedular\Auth\InstructorController; use Unsupervised\Schedular\Auth\InviteRepository; +use Unsupervised\Schedular\Auth\RegistrationApprovalController; use Unsupervised\Schedular\Auth\RegistrationController; +use Unsupervised\Schedular\Auth\RegistrationMailer; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\StudentController; use Unsupervised\Schedular\Booking\BookingRepository; @@ -38,6 +40,7 @@ class AdminMenu { private QuestionController $questionController; private PolicyController $policyController; private RegistrationController $registrationController; + private RegistrationApprovalController $registrationApprovalController; private GroupClassController $groupClassController; private StudentController $studentController; private InstructorController $instructorController; @@ -47,19 +50,20 @@ class AdminMenu { private PaymentReportController $paymentReportController; public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites, EnrollmentRepository $enrollments, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver ) { - $this->availabilityController = new AvailabilityController( $availability, $offerings ); - $this->lessonController = new LessonController( $bookings, $payments, $availability ); - $this->offeringController = new OfferingController( $offerings ); - $this->questionController = new QuestionController( $questions, $offerings ); - $this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); - $this->registrationController = new RegistrationController( $invites ); - $this->groupClassController = new GroupClassController( $enrollments, $offerings ); - $this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver ); - $this->instructorController = new InstructorController(); - $this->settings = $settings; - $this->accessSettings = new AccessSettings(); - $this->paymentController = new PaymentController( $payments, $paymentService ); - $this->paymentReportController = new PaymentReportController( $payments ); + $this->availabilityController = new AvailabilityController( $availability, $offerings ); + $this->lessonController = new LessonController( $bookings, $payments, $availability ); + $this->offeringController = new OfferingController( $offerings ); + $this->questionController = new QuestionController( $questions, $offerings ); + $this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); + $this->registrationController = new RegistrationController( $invites ); + $this->registrationApprovalController = new RegistrationApprovalController( new RegistrationMailer() ); + $this->groupClassController = new GroupClassController( $enrollments, $offerings ); + $this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver ); + $this->instructorController = new InstructorController(); + $this->settings = $settings; + $this->accessSettings = new AccessSettings(); + $this->paymentController = new PaymentController( $payments, $paymentService ); + $this->paymentReportController = new PaymentReportController( $payments ); } public function register(): void { @@ -168,6 +172,16 @@ class AdminMenu { 35 ); + // Studio admin: approve or reject self-signup students (open registration). + add_submenu_page( + 'us-students', + __( 'Pending Students', 'unsupervised-schedular' ), + __( 'Pending Students', 'unsupervised-schedular' ), + RoleManager::CAP_MANAGE_STUDENTS, + RegistrationApprovalController::PAGE_SLUG, + [ $this->registrationApprovalController, 'renderPage' ] + ); + // Studio admin: confirm pending (e-transfer) payments. add_menu_page( __( 'Payments', 'unsupervised-schedular' ), diff --git a/src/Auth/EmailConfirmationHandler.php b/src/Auth/EmailConfirmationHandler.php new file mode 100644 index 0000000..e4144d7 --- /dev/null +++ b/src/Auth/EmailConfirmationHandler.php @@ -0,0 +1,128 @@ +` link + * is opened, then redirect back to the registration page with a result flag. + */ + public function maybeConfirm(): void { + if ( is_admin() ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the token is itself the capability-bearing secret (like a password-reset key); nonces do not apply to an emailed link. + $rawToken = sanitize_text_field( Val::string( wp_unslash( $_GET['us_confirm'] ?? '' ) ) ); + if ( '' === $rawToken ) { + return; + } + + $base = $this->registrationPageUrl(); + $userId = RegistrationStatus::userIdForToken( $rawToken ); + + if ( null === $userId || RegistrationStatus::isTokenExpired( $userId, gmdate( 'Y-m-d H:i:s' ) ) ) { + wp_safe_redirect( add_query_arg( 'us_confirmed', 'expired', $base ) ); + exit; + } + + RegistrationStatus::confirmEmail( $userId ); + + $user = get_user_by( 'id', $userId ); + if ( $user instanceof \WP_User ) { + $this->mailer->notifyAdminsPending( $user ); + } + + wp_safe_redirect( add_query_arg( 'us_confirmed', '1', $base ) ); + exit; + } + + /** + * Point WordPress's own "Register" links at the studio registration page + * while open registration is on and a page is configured. + */ + public function registerUrl( string $url ): string { + if ( ! $this->settings->openRegistrationEnabled() ) { + return $url; + } + + $pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) ); + + return $pageId > 0 ? (string) get_permalink( $pageId ) : $url; + } + + /** + * Redirect any `wp-login.php?action=register` request (GET or POST) to the + * studio registration page, so the bare native form — which cannot collect + * required policy acceptances — is never used. + */ + public function blockNativeRegistration(): void { + if ( ! $this->settings->openRegistrationEnabled() ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing decision; no state is changed here. + $action = sanitize_key( Val::string( wp_unslash( $_REQUEST['action'] ?? '' ) ) ); + if ( 'register' !== $action ) { + return; + } + + $pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) ); + if ( $pageId <= 0 ) { + return; + } + + wp_safe_redirect( (string) get_permalink( $pageId ) ); + exit; + } + + /** + * Fail-safe: reject any native registration attempt while open registration + * is on, so `register_new_user()` can never create a policy-less account. + * + * @param \WP_Error $errors Accumulated registration errors. + * @return \WP_Error + */ + public function blockRegistrationErrors( \WP_Error $errors ): \WP_Error { + if ( $this->settings->openRegistrationEnabled() ) { + $errors->add( + 'us_registration_redirect', + esc_html__( 'Please register on the studio registration page.', 'unsupervised-schedular' ) + ); + } + + return $errors; + } + + private function registrationPageUrl(): string { + $pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) ); + + return $pageId > 0 ? (string) get_permalink( $pageId ) : home_url( '/' ); + } +} diff --git a/src/Auth/RegistrationApprovalController.php b/src/Auth/RegistrationApprovalController.php new file mode 100644 index 0000000..08845af --- /dev/null +++ b/src/Auth/RegistrationApprovalController.php @@ -0,0 +1,102 @@ +handleAction(); + } + + $awaitingApproval = []; + $awaitingConfirmation = []; + foreach ( $this->pendingUsers() as $user ) { + if ( RegistrationStatus::emailConfirmed( (int) $user->ID ) ) { + $awaitingApproval[] = $user; + } else { + $awaitingConfirmation[] = $user; + } + } + + include USC_PLUGIN_DIR . 'templates/admin/registrations.php'; + } + + /** + * Approve or reject the posted user. Approval clears the pending flags and + * emails the student; rejection emails them, then hard-deletes the account so + * the email is freed to re-apply. + */ + private function handleAction(): void { + // Nonce is verified by the caller (renderPage) before this method runs. + // phpcs:disable WordPress.Security.NonceVerification.Missing + $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); + $userId = absint( Val::int( $_POST['user_id'] ?? 0 ) ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + + if ( $userId <= 0 || ! RegistrationStatus::isAwaitingApproval( $userId ) ) { + return; + } + + if ( 'approve' === $action ) { + RegistrationStatus::approve( $userId ); + $user = get_user_by( 'id', $userId ); + if ( $user instanceof \WP_User ) { + $this->mailer->sendApproved( $user ); + } + return; + } + + if ( 'reject' === $action ) { + $user = get_user_by( 'id', $userId ); + $email = $user instanceof \WP_User ? (string) $user->user_email : ''; + if ( '' !== $email ) { + $this->mailer->sendRejected( $email ); + } + + if ( ! function_exists( 'wp_delete_user' ) ) { + require_once ABSPATH . 'wp-admin/includes/user.php'; + } + wp_delete_user( $userId ); + } + } + + /** + * Every account still awaiting approval (confirmed or not). + * + * @return list<\WP_User> + */ + private function pendingUsers(): array { + return array_values( + array_filter( + get_users( + [ + 'meta_key' => RegistrationStatus::META_AWAITING_APPROVAL, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_value' => '1', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'number' => 500, + 'orderby' => 'user_registered', + 'order' => 'ASC', + ] + ), + static fn( mixed $user ): bool => $user instanceof \WP_User + ) + ); + } +} diff --git a/src/Auth/RegistrationLoginGate.php b/src/Auth/RegistrationLoginGate.php new file mode 100644 index 0000000..21e673f --- /dev/null +++ b/src/Auth/RegistrationLoginGate.php @@ -0,0 +1,61 @@ +ID ) + && ! RegistrationStatus::emailConfirmed( (int) $user->ID ) + ) { + return new \WP_Error( + 'us_email_unconfirmed', + esc_html__( 'Please confirm your email address before logging in — check your inbox for the confirmation link.', 'unsupervised-schedular' ) + ); + } + + return $user; + } + + /** + * Strip the booking capability from any account still awaiting approval, so a + * confirmed-but-unapproved student cannot book until a studio admin approves. + * + * @param array $allcaps All capabilities currently held. + * @param array $caps Required capabilities (unused). + * @param array $args Callback args (unused). + * @param mixed $user The user being checked (a WP_User in practice). + * @return array + */ + public function withholdBookingWhilePending( array $allcaps, array $caps, array $args, mixed $user ): array { + if ( $user instanceof \WP_User && RegistrationStatus::isAwaitingApproval( (int) $user->ID ) ) { + unset( $allcaps[ RoleManager::CAP_BOOK_LESSON ] ); + } + + return $allcaps; + } +} diff --git a/src/Auth/RegistrationMailer.php b/src/Auth/RegistrationMailer.php new file mode 100644 index 0000000..8f03a08 --- /dev/null +++ b/src/Auth/RegistrationMailer.php @@ -0,0 +1,113 @@ +user_email ) { + return false; + } + + $subject = sprintf( + /* translators: %s: site name */ + __( 'Confirm your email for %s', 'unsupervised-schedular' ), + $this->siteName() + ); + + $body = sprintf( + /* translators: 1: site name, 2: confirmation URL */ + __( "Thanks for signing up at %1\$s.\n\nPlease confirm your email address by opening this link:\n%2\$s\n\nOnce confirmed, a studio admin will review and approve your account. You'll get another email when it's ready.", 'unsupervised-schedular' ), + $this->siteName(), + $confirmUrl + ); + + return (bool) wp_mail( $user->user_email, $subject, $body ); + } + + /** + * Tell the studio admins a self-signup has confirmed their email and is + * waiting for approval. Sent to the site admin email. + */ + public function notifyAdminsPending( \WP_User $user ): bool { + $adminEmail = Val::string( get_option( 'admin_email', '' ) ); + if ( '' === $adminEmail ) { + return false; + } + + $subject = __( 'A new student is awaiting approval', 'unsupervised-schedular' ); + $body = sprintf( + /* translators: 1: student name, 2: student email */ + __( "%1\$s (%2\$s) has confirmed their email and is awaiting approval.\n\nReview them under Students → Pending Students in wp-admin.", 'unsupervised-schedular' ), + (string) $user->display_name, + (string) $user->user_email + ); + + return (bool) wp_mail( $adminEmail, $subject, $body ); + } + + /** + * Tell the student their account has been approved. Returns false when there + * is no recipient. + */ + public function sendApproved( \WP_User $user ): bool { + if ( '' === (string) $user->user_email ) { + return false; + } + + $subject = sprintf( + /* translators: %s: site name */ + __( 'Your %s account is approved', 'unsupervised-schedular' ), + $this->siteName() + ); + $body = sprintf( + /* translators: 1: site name, 2: login URL */ + __( "Good news — your account at %1\$s has been approved. You can now log in and book:\n%2\$s", 'unsupervised-schedular' ), + $this->siteName(), + wp_login_url() + ); + + return (bool) wp_mail( $user->user_email, $subject, $body ); + } + + /** + * Tell an applicant their registration was declined. Takes the email address + * directly, since the account is deleted as part of rejection. + */ + public function sendRejected( string $email ): bool { + if ( '' === $email ) { + return false; + } + + $subject = sprintf( + /* translators: %s: site name */ + __( 'Your %s registration', 'unsupervised-schedular' ), + $this->siteName() + ); + $body = sprintf( + /* translators: %s: site name */ + __( 'Thank you for your interest in %s. We are unable to approve your registration at this time. Please contact the studio if you have any questions.', 'unsupervised-schedular' ), + $this->siteName() + ); + + return (bool) wp_mail( $email, $subject, $body ); + } + + private function siteName(): string { + $name = (string) get_bloginfo( 'name' ); + + return '' !== $name ? $name : __( 'the studio', 'unsupervised-schedular' ); + } +} diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php index 1b48178..2196e9a 100644 --- a/src/Auth/RegistrationPage.php +++ b/src/Auth/RegistrationPage.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Auth; +use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Policy\AcceptanceRepository; use Unsupervised\Schedular\Policy\Policy; use Unsupervised\Schedular\Policy\PolicyAcceptance; @@ -12,11 +13,19 @@ use Unsupervised\Schedular\Val; class RegistrationPage { + /** Success signal: an invited student was created and logged in. */ + private const RESULT_INVITE = 'invite'; + + /** Success signal: a self-signup was created and must confirm their email. */ + private const RESULT_CONFIRM = 'confirm'; + public function __construct( private InviteRepository $invites, private PolicyRepository $policies, private PolicyVersionRepository $versions, private AcceptanceRepository $acceptances, + private StudioSettings $settings, + private RegistrationMailer $mailer, ) {} /** @@ -33,21 +42,26 @@ class RegistrationPage { $token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) ); // Only the token's hash is stored, so hash the submitted token for lookup. $invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null; + $open = $this->settings->openRegistrationEnabled(); - $error = ''; - $success = false; + $error = ''; + $successType = ''; if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) { - $result = $this->handleSubmit( $invite ); - if ( true === $result ) { - $success = true; + $result = $this->handleSubmit( $invite, $open ); + if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM ], true ) ) { + $successType = $result; } else { $error = $result; } } + // Result of an email-confirmation link (set by EmailConfirmationHandler's redirect). + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, not a state change. + $confirmResult = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) ); + $policyForms = $this->signupPolicies(); - $canRegister = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) ); + $canRegister = $open || ( null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) ) ); ob_start(); include USC_PLUGIN_DIR . 'templates/frontend/register-page.php'; @@ -80,11 +94,17 @@ class RegistrationPage { } /** - * Process the submitted registration. Returns true on success or an error - * message string on failure. + * Process the submitted registration. Returns a success signal + * ({@see RESULT_INVITE} or {@see RESULT_CONFIRM}) or an error message string + * on failure. + * + * The invite branch is tried first, so an invited student always completes + * signup regardless of whether open registration is enabled. */ - private function handleSubmit( ?Invite $invite ): string|bool { - if ( null === $invite || ! $invite->isAcceptable( current_time( 'mysql' ) ) ) { + private function handleSubmit( ?Invite $invite, bool $open ): string { + $inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) ); + + if ( ! $inviteValid && ! $open ) { return esc_html__( 'This invitation is invalid, expired, or has already been used.', 'unsupervised-schedular' ); } @@ -98,6 +118,16 @@ class RegistrationPage { return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' ); } + // The email is fixed by the invite when there is one; self-signups supply it. + if ( $inviteValid ) { + $email = $invite->email; + } else { + $email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) ); + if ( ! is_email( $email ) ) { + return esc_html__( 'Please enter a valid email address.', 'unsupervised-schedular' ); + } + } + $policyForms = $this->signupPolicies(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int in the array_map callback; slashes cannot survive integer coercion. $accepted = array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) ); @@ -109,17 +139,17 @@ class RegistrationPage { } } - if ( email_exists( $invite->email ) ) { + if ( email_exists( $email ) ) { return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' ); } $userId = wp_insert_user( [ - 'user_login' => $invite->email, - 'user_email' => $invite->email, + 'user_login' => $email, + 'user_email' => $email, 'user_pass' => $password, - 'display_name' => '' !== $displayName ? $displayName : $invite->email, - 'role' => $invite->role, + 'display_name' => '' !== $displayName ? $displayName : $email, + 'role' => $inviteValid ? $invite->role : RoleManager::STUDENT, ] ); @@ -128,12 +158,36 @@ class RegistrationPage { } $this->recordAcceptances( $policyForms, (int) $userId ); - $this->invites->markAccepted( (int) $invite->id, (int) $userId ); - wp_set_current_user( (int) $userId ); - wp_set_auth_cookie( (int) $userId ); + if ( $inviteValid ) { + $this->invites->markAccepted( (int) $invite->id, (int) $userId ); - return true; + wp_set_current_user( (int) $userId ); + wp_set_auth_cookie( (int) $userId ); + + return self::RESULT_INVITE; + } + + // Self-approval: hold the account pending, email a confirmation link, and + // do NOT log the user in — they must confirm and be approved first. + $rawToken = RegistrationStatus::markPending( (int) $userId ); + $user = get_user_by( 'id', (int) $userId ); + if ( $user instanceof \WP_User ) { + $this->mailer->sendConfirmation( $user, $this->confirmUrl( $rawToken ) ); + } + + return self::RESULT_CONFIRM; + } + + /** + * Build the email-confirmation URL for a raw token: the configured + * registration page (falling back to the home page) with `?us_confirm=`. + */ + private function confirmUrl( string $rawToken ): string { + $pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) ); + $base = $pageId > 0 ? (string) get_permalink( $pageId ) : home_url( '/' ); + + return add_query_arg( 'us_confirm', rawurlencode( $rawToken ), $base ); } /** diff --git a/src/Auth/RegistrationStatus.php b/src/Auth/RegistrationStatus.php new file mode 100644 index 0000000..a5bfa4b --- /dev/null +++ b/src/Auth/RegistrationStatus.php @@ -0,0 +1,135 @@ + self::META_CONFIRM_TOKEN, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_value' => self::hashToken( $rawToken ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'number' => 1, + 'fields' => 'ID', + ] + ); + + if ( [] === $users ) { + return null; + } + + return Val::int( $users[0] ); + } + + /** + * Whether the confirmation token for a user has passed its expiry, measured + * against the supplied `Y-m-d H:i:s` (UTC) timestamp. A user with no stored + * expiry is treated as expired (there is nothing valid to confirm). + */ + public static function isTokenExpired( int $userId, string $now ): bool { + $expires = Val::string( get_user_meta( $userId, self::META_CONFIRM_EXPIRES, true ) ); + if ( '' === $expires ) { + return true; + } + + $expiresTs = strtotime( $expires ); + $nowTs = strtotime( $now ); + if ( false === $expiresTs || false === $nowTs ) { + return true; + } + + return $nowTs > $expiresTs; + } +} diff --git a/src/Booking/BookingPage.php b/src/Booking/BookingPage.php index 7839918..ea1a821 100644 --- a/src/Booking/BookingPage.php +++ b/src/Booking/BookingPage.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Booking; +use Unsupervised\Schedular\Auth\RegistrationStatus; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Val; @@ -26,6 +27,10 @@ class BookingPage { ); } + if ( RegistrationStatus::isAwaitingApproval( get_current_user_id() ) ) { + return '

' . esc_html__( 'Your account is awaiting studio approval. You will be able to book once a studio admin approves it.', 'unsupervised-schedular' ) . '

'; + } + if ( ! current_user_can( RoleManager::CAP_BOOK_LESSON ) ) { return '

' . esc_html__( 'This page is for students only.', 'unsupervised-schedular' ) . '

'; } diff --git a/src/Payment/StudioSettings.php b/src/Payment/StudioSettings.php index 58b8a6d..e32ba8a 100644 --- a/src/Payment/StudioSettings.php +++ b/src/Payment/StudioSettings.php @@ -16,6 +16,18 @@ class StudioSettings { public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email'; public const OPT_HST_RATE = 'us_hst_rate'; + public const OPT_REGISTRATION_MODE = 'us_registration_mode'; + public const MODE_INVITE = 'invite'; + public const MODE_SELF_APPROVAL = 'self_approval'; + + /** + * Snapshots of the two core WordPress options this feature takes over while + * open registration is enabled, so disabling restores them exactly rather + * than clobbering a site that set them for its own reasons. + */ + public const OPT_PREV_USERS_CAN_REGISTER = 'us_registration_prev_can_register'; + public const OPT_PREV_DEFAULT_ROLE = 'us_registration_prev_default_role'; + public function publishableKey(): string { return Val::string( get_option( self::OPT_PUBLISHABLE, '' ) ); } @@ -65,6 +77,24 @@ class StudioSettings { return '' !== $this->publishableKey() && '' !== $this->secretKey(); } + /** + * Which student registration mode is active: `invite` (default) — only a + * valid invite token grants the registration form — or `self_approval` — + * anyone may sign up, confirm their email, and await studio approval. + */ + public function registrationMode(): string { + return self::MODE_SELF_APPROVAL === get_option( self::OPT_REGISTRATION_MODE, self::MODE_INVITE ) + ? self::MODE_SELF_APPROVAL + : self::MODE_INVITE; + } + + /** + * Whether anyone may self-register (the `self_approval` mode). + */ + public function openRegistrationEnabled(): bool { + return self::MODE_SELF_APPROVAL === $this->registrationMode(); + } + public function renderPage(): void { if ( ! current_user_can( RoleManager::CAP_MANAGE_BILLING ) ) { wp_die( esc_html__( 'You do not have permission to manage billing settings.', 'unsupervised-schedular' ) ); @@ -86,6 +116,7 @@ class StudioSettings { $etransferEmail = $this->etransferEmail(); $hstRate = $this->hstRate(); $stripeConfigured = $this->isStripeConfigured(); + $openRegistration = $this->openRegistrationEnabled(); include USC_PLUGIN_DIR . 'templates/admin/settings.php'; } @@ -111,6 +142,43 @@ class StudioSettings { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion. $hstRate = isset( $_POST['hst_rate'] ) ? Val::float( $_POST['hst_rate'] ) : 0.0; update_option( self::OPT_HST_RATE, max( 0.0, $hstRate ) ); + + $this->applyRegistrationMode( isset( $_POST['open_registration'] ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing } + + /** + * Enable or disable open (self-approval) registration, mirroring the change + * into the two core WordPress options it depends on. + * + * Enabling snapshots the current `users_can_register` and `default_role`, + * then turns registration on and makes Student the default new-user role. + * Disabling restores that snapshot, so this toggle never permanently + * overwrites a site's own membership settings. Only transitions act, so + * saving unrelated settings leaves the core options untouched. + */ + private function applyRegistrationMode( bool $enable ): void { + $currentlyOpen = $this->openRegistrationEnabled(); + + if ( $enable && ! $currentlyOpen ) { + update_option( self::OPT_PREV_USERS_CAN_REGISTER, get_option( 'users_can_register' ) ? '1' : '0' ); + update_option( self::OPT_PREV_DEFAULT_ROLE, Val::string( get_option( 'default_role', 'subscriber' ) ) ); + + update_option( 'users_can_register', '1' ); + update_option( 'default_role', RoleManager::STUDENT ); + update_option( self::OPT_REGISTRATION_MODE, self::MODE_SELF_APPROVAL ); + return; + } + + if ( ! $enable && $currentlyOpen ) { + $prevCanRegister = '1' === Val::string( get_option( self::OPT_PREV_USERS_CAN_REGISTER, '0' ) ); + $prevRole = Val::string( get_option( self::OPT_PREV_DEFAULT_ROLE, 'subscriber' ) ); + + update_option( 'users_can_register', $prevCanRegister ? '1' : '0' ); + update_option( 'default_role', '' !== $prevRole ? $prevRole : 'subscriber' ); + delete_option( self::OPT_PREV_USERS_CAN_REGISTER ); + delete_option( self::OPT_PREV_DEFAULT_ROLE ); + update_option( self::OPT_REGISTRATION_MODE, self::MODE_INVITE ); + } + } } diff --git a/src/Plugin.php b/src/Plugin.php index cf8be22..d883c96 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -3,8 +3,11 @@ declare(strict_types=1); namespace Unsupervised\Schedular; +use Unsupervised\Schedular\Auth\EmailConfirmationHandler; use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\LoginPage; +use Unsupervised\Schedular\Auth\RegistrationLoginGate; +use Unsupervised\Schedular\Auth\RegistrationMailer; use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Booking\BookingPage; @@ -64,12 +67,16 @@ class Plugin { // The shortcode and block wrappers share the same page objects so // front-end output is identical whichever way a page embeds them. + $registrationMailer = new RegistrationMailer(); + $bookingPage = new BookingPage(); $loginPage = new LoginPage(); - $registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances ); + $registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer ); $groupClassPage = new GroupClassPage(); ( new RoleManager() )->register(); + ( new RegistrationLoginGate() )->register(); + ( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register(); ( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register(); ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $paymentService ) )->register(); ( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register(); diff --git a/templates/admin/registrations.php b/templates/admin/registrations.php new file mode 100644 index 0000000..0e72301 --- /dev/null +++ b/templates/admin/registrations.php @@ -0,0 +1,92 @@ + $awaitingApproval Confirmed, awaiting a decision. + * @var array $awaitingConfirmation Not yet confirmed their email. + */ +?> +
+

+ +
+

+ +

+
+ +

+ +

+ + + + + + + + + + + + + + + + + + + + +
display_name); ?>user_email); ?>user_registered); ?> +
+ + + +
+
+ + + +
+
+ + +

+ +

+ + + + + + + + + + + + + + + + + + + + +
display_name); ?>user_email); ?>user_registered); ?> +
+ + + +
+
+ +
diff --git a/templates/admin/settings.php b/templates/admin/settings.php index e405361..c4d3ba0 100644 --- a/templates/admin/settings.php +++ b/templates/admin/settings.php @@ -15,6 +15,7 @@ if (! defined('ABSPATH')) { * @var string $etransferEmail * @var float $hstRate * @var bool $stripeConfigured + * @var bool $openRegistration */ ?>
@@ -88,6 +89,27 @@ if (! defined('ABSPATH')) { + +

+ + + + + +
+
+ +

+ +

+

+ +

+
+
diff --git a/templates/frontend/register-page.php b/templates/frontend/register-page.php index 2fc8366..3b16af6 100644 --- a/templates/frontend/register-page.php +++ b/templates/frontend/register-page.php @@ -9,60 +9,76 @@ if (! defined('ABSPATH')) { * @var \Unsupervised\Schedular\Auth\Invite|null $invite * @var string $token Raw invite token from the request (only its hash is stored). * @var bool $canRegister - * @var bool $success + * @var bool $open Whether open (self-approval) registration is enabled. + * @var string $successType '' | 'invite' (created + logged in) | 'confirm' (check email). + * @var string $confirmResult '' | '1' (email confirmed) | 'expired'. * @var string $error * @var list $policyForms */ ?>
- +

- -

+ +

- - + +

+ + -
- - - -

- - -

-

- - -

-

- - -

- - -
- - -
-

title); ?>

-
body); ?>
- -
- -
+ +

+ + + -

- -

-
+
+ + + +

+ + + + + + +

+

+ + +

+

+ + +

+ + +
+ + +
+

title); ?>

+
body); ?>
+ +
+ +
+ + +

+ +

+
+
diff --git a/tests/Unit/Auth/EmailConfirmationHandlerTest.php b/tests/Unit/Auth/EmailConfirmationHandlerTest.php new file mode 100644 index 0000000..7bb1a71 --- /dev/null +++ b/tests/Unit/Auth/EmailConfirmationHandlerTest.php @@ -0,0 +1,105 @@ +alias(static fn ($v) => $v); + Functions\when('sanitize_key')->alias(static fn ($v) => $v); + } + + protected function tearDown(): void + { + unset($_REQUEST['action']); + parent::tearDown(); + } + + private function handler(): EmailConfirmationHandler + { + return new EmailConfirmationHandler(new StudioSettings(), new RegistrationMailer()); + } + + private function stubMode(string $mode): void + { + Functions\when('get_option')->alias(static function (string $name, $default = false) use ($mode) { + if ($name === StudioSettings::OPT_REGISTRATION_MODE) { + return $mode; + } + if ($name === RegistrationController::OPTION_PAGE) { + return 5; + } + return $default; + }); + } + + public function testRegisterUrlPassesThroughWhenClosed(): void + { + $this->stubMode(StudioSettings::MODE_INVITE); + + self::assertSame('http://wp/register', $this->handler()->registerUrl('http://wp/register')); + } + + public function testRegisterUrlPointsAtRegistrationPageWhenOpen(): void + { + $this->stubMode(StudioSettings::MODE_SELF_APPROVAL); + Functions\when('get_permalink')->justReturn('http://studio.test/register'); + + self::assertSame('http://studio.test/register', $this->handler()->registerUrl('http://wp/register')); + } + + public function testBlockNativeRegistrationIsNoOpWhenClosed(): void + { + $this->stubMode(StudioSettings::MODE_INVITE); + $_REQUEST['action'] = 'register'; + + // Must not redirect/exit when open registration is off. + Functions\expect('wp_safe_redirect')->never(); + + $this->handler()->blockNativeRegistration(); + + unset($_REQUEST['action']); + self::assertTrue(true); + } + + public function testBlockNativeRegistrationIgnoresOtherActions(): void + { + $this->stubMode(StudioSettings::MODE_SELF_APPROVAL); + $_REQUEST['action'] = 'lostpassword'; + + Functions\expect('wp_safe_redirect')->never(); + + $this->handler()->blockNativeRegistration(); + + unset($_REQUEST['action']); + self::assertTrue(true); + } + + public function testBlockRegistrationErrorsRejectsWhenOpen(): void + { + $this->stubMode(StudioSettings::MODE_SELF_APPROVAL); + + $errors = $this->handler()->blockRegistrationErrors(new \WP_Error()); + + self::assertSame('us_registration_redirect', $errors->get_error_code()); + } + + public function testBlockRegistrationErrorsPassesThroughWhenClosed(): void + { + $this->stubMode(StudioSettings::MODE_INVITE); + + $errors = $this->handler()->blockRegistrationErrors(new \WP_Error()); + + self::assertSame('', $errors->get_error_code()); + } +} diff --git a/tests/Unit/Auth/RegistrationApprovalControllerTest.php b/tests/Unit/Auth/RegistrationApprovalControllerTest.php new file mode 100644 index 0000000..dfc43d3 --- /dev/null +++ b/tests/Unit/Auth/RegistrationApprovalControllerTest.php @@ -0,0 +1,96 @@ +alias(static fn ($v) => $v); + Functions\when('sanitize_key')->alias(static fn ($v) => $v); + Functions\when('absint')->alias(static fn ($v) => (int) $v); + $_POST = []; + } + + protected function tearDown(): void + { + $_POST = []; + parent::tearDown(); + } + + public function testRenderPageDiesWithoutCapability(): void + { + Functions\when('current_user_can')->justReturn(false); + Functions\when('wp_die')->alias(static function (): void { + throw new \RuntimeException('wp_die'); + }); + + $this->expectException(\RuntimeException::class); + + (new RegistrationApprovalController(Mockery::mock(RegistrationMailer::class)))->renderPage(); + } + + public function testApproveClearsPendingMetaAndEmailsStudent(): void + { + $_POST = [ 'usc_action' => 'approve', 'user_id' => 7 ]; + + Functions\when('get_user_meta')->justReturn('1'); // awaiting approval + Functions\expect('delete_user_meta')->atLeast()->once(); + + $user = Mockery::mock(\WP_User::class); + Functions\when('get_user_by')->justReturn($user); + + $mailer = Mockery::mock(RegistrationMailer::class); + $mailer->shouldReceive('sendApproved')->once()->with($user); + + $this->handleAction(new RegistrationApprovalController($mailer)); + } + + public function testRejectEmailsAndHardDeletesTheAccount(): void + { + $_POST = [ 'usc_action' => 'reject', 'user_id' => 7 ]; + + Functions\when('get_user_meta')->justReturn('1'); // awaiting approval + + $user = Mockery::mock(\WP_User::class); + $user->user_email = 'a@b.test'; + Functions\when('get_user_by')->justReturn($user); + + $mailer = Mockery::mock(RegistrationMailer::class); + $mailer->shouldReceive('sendRejected')->once()->with('a@b.test'); + + Functions\expect('wp_delete_user')->once()->with(7); + + $this->handleAction(new RegistrationApprovalController($mailer)); + } + + public function testIgnoresUserNotAwaitingApproval(): void + { + $_POST = [ 'usc_action' => 'approve', 'user_id' => 7 ]; + + Functions\when('get_user_meta')->justReturn(''); // not pending + + $mailer = Mockery::mock(RegistrationMailer::class); + $mailer->shouldReceive('sendApproved')->never(); + + $this->handleAction(new RegistrationApprovalController($mailer)); + + // isAwaitingApproval guarded the action; nothing happened. + self::assertFalse(RegistrationStatus::isAwaitingApproval(7)); + } + + private function handleAction(RegistrationApprovalController $controller): void + { + $method = new \ReflectionMethod(RegistrationApprovalController::class, 'handleAction'); + $method->invoke($controller); + } +} diff --git a/tests/Unit/Auth/RegistrationLoginGateTest.php b/tests/Unit/Auth/RegistrationLoginGateTest.php new file mode 100644 index 0000000..7595e8d --- /dev/null +++ b/tests/Unit/Auth/RegistrationLoginGateTest.php @@ -0,0 +1,118 @@ +alias(static function (int $id, string $key) use ($awaiting, $confirmed) { + if ($key === RegistrationStatus::META_AWAITING_APPROVAL) { + return $awaiting; + } + if ($key === RegistrationStatus::META_EMAIL_CONFIRMED) { + return $confirmed; + } + return ''; + }); + } + + private function user(int $id): \WP_User + { + $user = Mockery::mock(\WP_User::class); + $user->ID = $id; + + return $user; + } + + public function testBlocksLoginWhileEmailUnconfirmed(): void + { + $this->stubMeta('1', ''); + + $result = (new RegistrationLoginGate())->blockUnconfirmed($this->user(7)); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('us_email_unconfirmed', $result->get_error_code()); + } + + public function testAllowsLoginWhenConfirmedButAwaitingApproval(): void + { + $this->stubMeta('1', '1'); + + $user = $this->user(7); + + self::assertSame($user, (new RegistrationLoginGate())->blockUnconfirmed($user)); + } + + public function testAllowsLoginForApprovedStudent(): void + { + $this->stubMeta('', '1'); + + $user = $this->user(7); + + self::assertSame($user, (new RegistrationLoginGate())->blockUnconfirmed($user)); + } + + public function testIgnoresUsersWithNoPendingMeta(): void + { + // Invite/admin-created students carry no meta at all. + $this->stubMeta('', ''); + + $user = $this->user(7); + + self::assertSame($user, (new RegistrationLoginGate())->blockUnconfirmed($user)); + } + + public function testPassesThroughAnEarlierError(): void + { + $error = new \WP_Error('some_earlier_error', 'nope'); + + self::assertSame($error, (new RegistrationLoginGate())->blockUnconfirmed($error)); + } + + public function testWithholdsBookingCapWhileAwaitingApproval(): void + { + $this->stubMeta('1', '1'); + + $allcaps = [ RoleManager::CAP_BOOK_LESSON => true, 'read' => true ]; + + $result = (new RegistrationLoginGate()) + ->withholdBookingWhilePending($allcaps, [], [], $this->user(7)); + + self::assertArrayNotHasKey(RoleManager::CAP_BOOK_LESSON, $result); + self::assertTrue($result['read']); + } + + public function testKeepsBookingCapForApprovedStudent(): void + { + $this->stubMeta('', '1'); + + $allcaps = [ RoleManager::CAP_BOOK_LESSON => true ]; + + $result = (new RegistrationLoginGate()) + ->withholdBookingWhilePending($allcaps, [], [], $this->user(7)); + + self::assertTrue($result[RoleManager::CAP_BOOK_LESSON]); + } + + public function testLeavesCapsUntouchedForNonUserArg(): void + { + $allcaps = [ RoleManager::CAP_BOOK_LESSON => true ]; + + $result = (new RegistrationLoginGate()) + ->withholdBookingWhilePending($allcaps, [], [], null); + + self::assertSame($allcaps, $result); + } +} diff --git a/tests/Unit/Auth/RegistrationMailerTest.php b/tests/Unit/Auth/RegistrationMailerTest.php new file mode 100644 index 0000000..dfa7d5d --- /dev/null +++ b/tests/Unit/Auth/RegistrationMailerTest.php @@ -0,0 +1,84 @@ +justReturn('Test Studio'); + Functions\when('wp_login_url')->justReturn('http://example.test/login'); + } + + private function user(string $email): \WP_User + { + $user = Mockery::mock(\WP_User::class); + $user->user_email = $email; + $user->display_name = 'Ada'; + + return $user; + } + + public function testSendConfirmationEmailsTheStudent(): void + { + Functions\expect('wp_mail') + ->once() + ->with( + 'a@b.test', + Mockery::type('string'), + Mockery::on(static fn (string $body): bool => str_contains($body, 'http://confirm.test')) + ) + ->andReturn(true); + + self::assertTrue((new RegistrationMailer())->sendConfirmation($this->user('a@b.test'), 'http://confirm.test')); + } + + public function testSendConfirmationReturnsFalseWithoutRecipient(): void + { + self::assertFalse((new RegistrationMailer())->sendConfirmation($this->user(''), 'http://confirm.test')); + } + + public function testNotifyAdminsPendingUsesAdminEmail(): void + { + Functions\when('get_option')->alias(static fn (string $name) => $name === 'admin_email' ? 'admin@studio.test' : ''); + + Functions\expect('wp_mail') + ->once() + ->with('admin@studio.test', Mockery::type('string'), Mockery::type('string')) + ->andReturn(true); + + self::assertTrue((new RegistrationMailer())->notifyAdminsPending($this->user('a@b.test'))); + } + + public function testSendApprovedEmailsTheStudent(): void + { + Functions\expect('wp_mail') + ->once() + ->with('a@b.test', Mockery::type('string'), Mockery::type('string')) + ->andReturn(true); + + self::assertTrue((new RegistrationMailer())->sendApproved($this->user('a@b.test'))); + } + + public function testSendRejectedEmailsTheAddress(): void + { + Functions\expect('wp_mail') + ->once() + ->with('a@b.test', Mockery::type('string'), Mockery::type('string')) + ->andReturn(true); + + self::assertTrue((new RegistrationMailer())->sendRejected('a@b.test')); + } + + public function testSendRejectedReturnsFalseWithoutRecipient(): void + { + self::assertFalse((new RegistrationMailer())->sendRejected('')); + } +} diff --git a/tests/Unit/Auth/RegistrationPageTest.php b/tests/Unit/Auth/RegistrationPageTest.php new file mode 100644 index 0000000..bc40c38 --- /dev/null +++ b/tests/Unit/Auth/RegistrationPageTest.php @@ -0,0 +1,141 @@ + */ + private array $ctx; + + protected function setUp(): void + { + parent::setUp(); + + Functions\when('wp_unslash')->alias(static fn ($v) => $v); + Functions\when('sanitize_text_field')->alias(static fn ($v) => $v); + Functions\when('sanitize_email')->alias(static fn ($v) => $v); + Functions\when('current_time')->justReturn('2024-01-01 00:00:00'); + + $invites = Mockery::mock(InviteRepository::class); + $policies = Mockery::mock(PolicyRepository::class); + $policies->shouldReceive('findForScope')->andReturn([])->byDefault(); + + $this->ctx = [ + 'invites' => $invites, + 'policies' => $policies, + 'mailer' => Mockery::mock(RegistrationMailer::class), + 'settings' => Mockery::mock(StudioSettings::class), + ]; + + $this->ctx['page'] = new RegistrationPage( + $invites, + $policies, + Mockery::mock(PolicyVersionRepository::class), + Mockery::mock(AcceptanceRepository::class), + $this->ctx['settings'], + $this->ctx['mailer'], + ); + + $_POST = []; + } + + protected function tearDown(): void + { + $_POST = []; + parent::tearDown(); + } + + private function submit(?Invite $invite, bool $open): string + { + $method = new \ReflectionMethod(RegistrationPage::class, 'handleSubmit'); + + return (string) $method->invoke($this->ctx['page'], $invite, $open); + } + + public function testInviteBranchCreatesAndLogsInTheStudent(): void + { + $_POST = [ 'password' => 'password123', 'display_name' => 'Ada' ]; + + Functions\when('email_exists')->justReturn(false); + Functions\when('wp_insert_user')->justReturn(42); + Functions\when('is_wp_error')->justReturn(false); + + $this->ctx['invites']->shouldReceive('markAccepted')->once(); + Functions\expect('wp_set_current_user')->once()->with(42); + Functions\expect('wp_set_auth_cookie')->once()->with(42); + + $invite = new Invite(email: 'a@b.test', token: 'hash'); + + self::assertSame('invite', $this->submit($invite, false)); + } + + public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void + { + $_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; + + Functions\when('is_email')->justReturn(true); + Functions\when('email_exists')->justReturn(false); + Functions\when('wp_insert_user')->justReturn(42); + Functions\when('is_wp_error')->justReturn(false); + // markPending internals + Functions\when('wp_generate_password')->justReturn('rawtok'); + Functions\when('update_user_meta')->justReturn(true); + // confirmUrl internals + Functions\when('get_option')->justReturn(0); + Functions\when('home_url')->justReturn('http://home.test/'); + Functions\when('add_query_arg')->alias(static fn (string $k, string $v, string $u): string => $u . '?' . $k . '=' . $v); + + $user = Mockery::mock(\WP_User::class); + Functions\when('get_user_by')->justReturn($user); + + $this->ctx['mailer']->shouldReceive('sendConfirmation')->once()->with($user, Mockery::type('string')); + // No invite acceptance and no auto-login in the open branch. + $this->ctx['invites']->shouldReceive('markAccepted')->never(); + Functions\expect('wp_set_auth_cookie')->never(); + + self::assertSame('confirm', $this->submit(null, true)); + } + + public function testClosedModeWithoutInviteReturnsError(): void + { + $result = $this->submit(null, false); + + self::assertNotSame('invite', $result); + self::assertNotSame('confirm', $result); + self::assertNotSame('', $result); + } + + public function testRejectsWhenARequiredPolicyIsUnaccepted(): void + { + $_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; + + Functions\when('is_email')->justReturn(true); + + $policy = new Policy(title: 'Terms', slug: 'terms', currentVersionId: 3); + $version = new PolicyVersion(policyId: 1, versionNumber: 1, status: PolicyVersion::STATUS_PUBLISHED, id: 3); + + $this->ctx['policies']->shouldReceive('findForScope')->andReturn([ $policy ]); + $versionRepo = (new \ReflectionProperty(RegistrationPage::class, 'versions'))->getValue($this->ctx['page']); + $versionRepo->shouldReceive('findById')->with(3)->andReturn($version); + + $result = $this->submit(null, true); + + self::assertNotSame('confirm', $result); + self::assertNotSame('', $result); + } +} diff --git a/tests/Unit/Auth/RegistrationStatusTest.php b/tests/Unit/Auth/RegistrationStatusTest.php new file mode 100644 index 0000000..f2d05ec --- /dev/null +++ b/tests/Unit/Auth/RegistrationStatusTest.php @@ -0,0 +1,106 @@ +justReturn('rawtoken'); + + Functions\expect('update_user_meta') + ->once() + ->with(7, RegistrationStatus::META_AWAITING_APPROVAL, '1'); + Functions\expect('update_user_meta') + ->once() + ->with(7, RegistrationStatus::META_CONFIRM_TOKEN, hash('sha256', 'rawtoken')); + Functions\expect('update_user_meta') + ->once() + ->with(7, RegistrationStatus::META_CONFIRM_EXPIRES, \Mockery::type('string')); + + self::assertSame('rawtoken', RegistrationStatus::markPending(7)); + } + + public function testConfirmEmailSetsFlagAndClearsToken(): void + { + Functions\expect('update_user_meta') + ->once() + ->with(7, RegistrationStatus::META_EMAIL_CONFIRMED, '1'); + Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_CONFIRM_TOKEN); + Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_CONFIRM_EXPIRES); + + RegistrationStatus::confirmEmail(7); + } + + public function testApproveClearsPendingFlags(): void + { + Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_AWAITING_APPROVAL); + Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_CONFIRM_TOKEN); + Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_CONFIRM_EXPIRES); + + RegistrationStatus::approve(7); + } + + public function testAwaitingApprovalAndEmailConfirmedReadMeta(): void + { + Functions\when('get_user_meta')->alias(static function (int $id, string $key) { + return $key === RegistrationStatus::META_AWAITING_APPROVAL ? '1' : ''; + }); + + self::assertTrue(RegistrationStatus::isAwaitingApproval(7)); + self::assertFalse(RegistrationStatus::emailConfirmed(7)); + } + + public function testUserIdForTokenLooksUpByHashOnly(): void + { + Functions\expect('get_users') + ->once() + ->with(\Mockery::on(static fn (array $args): bool => + $args['meta_key'] === RegistrationStatus::META_CONFIRM_TOKEN + && $args['meta_value'] === hash('sha256', 'rawtoken'))) + ->andReturn([9]); + + self::assertSame(9, RegistrationStatus::userIdForToken('rawtoken')); + } + + public function testUserIdForTokenReturnsNullWhenNoMatch(): void + { + Functions\when('get_users')->justReturn([]); + + self::assertNull(RegistrationStatus::userIdForToken('rawtoken')); + } + + public function testEmptyTokenShortCircuits(): void + { + // get_users must never be called for an empty token. + Functions\expect('get_users')->never(); + + self::assertNull(RegistrationStatus::userIdForToken('')); + } + + public function testTokenExpiredWhenExpiryPassed(): void + { + Functions\when('get_user_meta')->justReturn('2020-01-01 00:00:00'); + + self::assertTrue(RegistrationStatus::isTokenExpired(7, '2020-01-02 00:00:00')); + } + + public function testTokenNotExpiredWhenExpiryInFuture(): void + { + Functions\when('get_user_meta')->justReturn('2020-01-03 00:00:00'); + + self::assertFalse(RegistrationStatus::isTokenExpired(7, '2020-01-02 00:00:00')); + } + + public function testMissingExpiryTreatedAsExpired(): void + { + Functions\when('get_user_meta')->justReturn(''); + + self::assertTrue(RegistrationStatus::isTokenExpired(7, '2020-01-02 00:00:00')); + } +} diff --git a/tests/Unit/Payment/StudioSettingsTest.php b/tests/Unit/Payment/StudioSettingsTest.php new file mode 100644 index 0000000..4d76ef6 --- /dev/null +++ b/tests/Unit/Payment/StudioSettingsTest.php @@ -0,0 +1,88 @@ +alias(static fn (string $name, $default) => $default); + + $settings = new StudioSettings(); + + self::assertSame(StudioSettings::MODE_INVITE, $settings->registrationMode()); + self::assertFalse($settings->openRegistrationEnabled()); + } + + public function testOpenRegistrationEnabledWhenStored(): void + { + Functions\when('get_option')->alias(static fn (string $name) => + $name === StudioSettings::OPT_REGISTRATION_MODE ? StudioSettings::MODE_SELF_APPROVAL : ''); + + self::assertTrue((new StudioSettings())->openRegistrationEnabled()); + } + + public function testEnablingMirrorsCoreOptionsAndSnapshots(): void + { + Functions\when('get_option')->alias(static function (string $name, $default = false) { + return match ($name) { + StudioSettings::OPT_REGISTRATION_MODE => StudioSettings::MODE_INVITE, // currently closed + 'users_can_register' => false, // snapshot as '0' + 'default_role' => 'contributor', + default => $default, + }; + }); + + Functions\expect('update_option')->once()->with(StudioSettings::OPT_PREV_USERS_CAN_REGISTER, '0'); + Functions\expect('update_option')->once()->with(StudioSettings::OPT_PREV_DEFAULT_ROLE, 'contributor'); + Functions\expect('update_option')->once()->with('users_can_register', '1'); + Functions\expect('update_option')->once()->with('default_role', RoleManager::STUDENT); + Functions\expect('update_option')->once()->with(StudioSettings::OPT_REGISTRATION_MODE, StudioSettings::MODE_SELF_APPROVAL); + + $this->applyMode(true); + } + + public function testDisablingRestoresSnapshot(): void + { + Functions\when('get_option')->alias(static function (string $name, $default = false) { + return match ($name) { + StudioSettings::OPT_REGISTRATION_MODE => StudioSettings::MODE_SELF_APPROVAL, // currently open + StudioSettings::OPT_PREV_USERS_CAN_REGISTER => '1', + StudioSettings::OPT_PREV_DEFAULT_ROLE => 'contributor', + default => $default, + }; + }); + + Functions\expect('update_option')->once()->with('users_can_register', '1'); + Functions\expect('update_option')->once()->with('default_role', 'contributor'); + Functions\expect('delete_option')->once()->with(StudioSettings::OPT_PREV_USERS_CAN_REGISTER); + Functions\expect('delete_option')->once()->with(StudioSettings::OPT_PREV_DEFAULT_ROLE); + Functions\expect('update_option')->once()->with(StudioSettings::OPT_REGISTRATION_MODE, StudioSettings::MODE_INVITE); + + $this->applyMode(false); + } + + public function testNoTransitionLeavesCoreOptionsUntouched(): void + { + // Already open, asked to enable again: nothing should be written. + Functions\when('get_option')->alias(static fn (string $name, $default = false) => + $name === StudioSettings::OPT_REGISTRATION_MODE ? StudioSettings::MODE_SELF_APPROVAL : $default); + + Functions\expect('update_option')->never(); + Functions\expect('delete_option')->never(); + + $this->applyMode(true); + } + + private function applyMode(bool $enable): void + { + $method = new \ReflectionMethod(StudioSettings::class, 'applyRegistrationMode'); + $method->invoke(new StudioSettings(), $enable); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8705a68..aecf305 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -104,5 +104,13 @@ if (! class_exists('WP_Error')) { $code = $this->get_error_code(); return '' !== $code ? ($this->errors[$code][0] ?? '') : ''; } + + public function add(string $code, string $message = '', mixed $data = ''): void + { + $this->errors[$code][] = $message; + if ('' !== $data) { + $this->error_data[$code] = $data; + } + } } }