Open student registration with email confirmation and admin approval #64
@@ -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
|
||||
|
||||
|
||||
@@ -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=<token>` 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=<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.
|
||||
|
||||
## 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`
|
||||
|
||||
+27
-13
@@ -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' ),
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Handles the self-signup email-confirmation link, and keeps WordPress's own
|
||||
* registration form from being used to bypass the studio's policy-accepting
|
||||
* registration page while open registration is enabled.
|
||||
*/
|
||||
class EmailConfirmationHandler {
|
||||
|
||||
public function __construct(
|
||||
private StudioSettings $settings,
|
||||
private RegistrationMailer $mailer,
|
||||
) {}
|
||||
|
||||
public function register(): void {
|
||||
add_action( 'template_redirect', [ $this, 'maybeConfirm' ] );
|
||||
add_filter( 'register_url', [ $this, 'registerUrl' ] );
|
||||
// login_init fires at the top of wp-login.php for every request (GET form
|
||||
// display AND a direct POST) before any registration processing, so it is
|
||||
// the reliable choke point; registration_errors is a fail-safe in case a
|
||||
// POST ever reaches register_new_user().
|
||||
add_action( 'login_init', [ $this, 'blockNativeRegistration' ] );
|
||||
add_filter( 'registration_errors', [ $this, 'blockRegistrationErrors' ], 10, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a self-signup's email when the emailed `?us_confirm=<token>` 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( '/' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Admin page (Students → Pending Students) for reviewing self-signup accounts:
|
||||
* approve a confirmed applicant into a full student, or reject (delete) them.
|
||||
* Only relevant while open registration is enabled.
|
||||
*/
|
||||
class RegistrationApprovalController {
|
||||
|
||||
public const PAGE_SLUG = 'us-pending-students';
|
||||
public const NONCE_ACTION = 'usc_registration_approval';
|
||||
|
||||
public function __construct( private RegistrationMailer $mailer ) {}
|
||||
|
||||
public function renderPage(): void {
|
||||
if ( ! current_user_can( RoleManager::CAP_MANAGE_STUDENTS ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to manage student registrations.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( self::NONCE_ACTION ) ) {
|
||||
$this->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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
/**
|
||||
* Enforces the pending state of self-signup accounts:
|
||||
* - an account whose email is not yet confirmed cannot log in at all;
|
||||
* - a confirmed-but-unapproved account may log in, but its booking capability
|
||||
* is withheld so it only reaches the "awaiting approval" screen.
|
||||
*
|
||||
* Both checks key solely off the pending user meta, so invite- and
|
||||
* admin-created students (which carry none of it) are unaffected.
|
||||
*/
|
||||
class RegistrationLoginGate {
|
||||
|
||||
public function register(): void {
|
||||
add_filter( 'wp_authenticate_user', [ $this, 'blockUnconfirmed' ], 10, 1 );
|
||||
add_filter( 'user_has_cap', [ $this, 'withholdBookingWhilePending' ], 10, 4 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Block authentication for a self-signup that has not yet confirmed its
|
||||
* email. Runs after password verification.
|
||||
*
|
||||
* @param \WP_User|\WP_Error $user Authenticating user, or an earlier error.
|
||||
* @return \WP_User|\WP_Error
|
||||
*/
|
||||
public function blockUnconfirmed( $user ) {
|
||||
if (
|
||||
$user instanceof \WP_User
|
||||
&& RegistrationStatus::isAwaitingApproval( (int) $user->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<string, bool> $allcaps All capabilities currently held.
|
||||
* @param array<int, string> $caps Required capabilities (unused).
|
||||
* @param array<int, mixed> $args Callback args (unused).
|
||||
* @param mixed $user The user being checked (a WP_User in practice).
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Transactional emails for the self-approval registration flow: the email
|
||||
* confirmation link, the studio-admin heads-up that someone is ready to
|
||||
* approve, and the approval / rejection notices to the student.
|
||||
*/
|
||||
class RegistrationMailer {
|
||||
|
||||
/**
|
||||
* Email the new student a link to confirm their address. Returns false when
|
||||
* there is no recipient.
|
||||
*/
|
||||
public function sendConfirmation( \WP_User $user, string $confirmUrl ): bool {
|
||||
if ( '' === (string) $user->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' );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* The account lifecycle for a self-signup student, expressed entirely as user
|
||||
* meta so it lives alongside the WordPress user and needs no extra table.
|
||||
*
|
||||
* States (see {@see docs/features/account-registration.md}):
|
||||
* - Email unconfirmed — `us_awaiting_approval='1'`, no `us_email_confirmed`, a
|
||||
* hashed confirmation token + expiry set. Login is blocked.
|
||||
* - Confirmed, awaiting approval — `us_awaiting_approval='1'`,
|
||||
* `us_email_confirmed='1'`, token/expiry cleared. Login allowed but the
|
||||
* booking capability is withheld.
|
||||
* - Approved / active — `us_awaiting_approval` deleted; a normal student.
|
||||
*
|
||||
* Invite- and admin-created students carry none of these metas, so they behave
|
||||
* exactly as before.
|
||||
*/
|
||||
class RegistrationStatus {
|
||||
|
||||
public const META_AWAITING_APPROVAL = 'us_awaiting_approval';
|
||||
public const META_EMAIL_CONFIRMED = 'us_email_confirmed';
|
||||
public const META_CONFIRM_TOKEN = 'us_email_confirm_token';
|
||||
public const META_CONFIRM_EXPIRES = 'us_email_confirm_expires';
|
||||
|
||||
/**
|
||||
* Hours a self-signup email-confirmation link stays valid after the account
|
||||
* is created. Limits the window in which a leaked link can be redeemed.
|
||||
*/
|
||||
public const EMAIL_CONFIRM_EXPIRY_HOURS = 48;
|
||||
|
||||
/**
|
||||
* Hash a raw confirmation token for storage and lookup. Only the hash is
|
||||
* persisted (mirrors {@see Invite::hashToken()}), so a database leak cannot
|
||||
* be used to confirm an account — the raw token exists only in the email.
|
||||
*/
|
||||
public static function hashToken( string $rawToken ): string {
|
||||
return hash( 'sha256', $rawToken );
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a freshly created user into the pending state and issue an email
|
||||
* confirmation token. Returns the raw token to embed in the emailed link.
|
||||
*/
|
||||
public static function markPending( int $userId ): string {
|
||||
$rawToken = wp_generate_password( 32, false );
|
||||
|
||||
update_user_meta( $userId, self::META_AWAITING_APPROVAL, '1' );
|
||||
update_user_meta( $userId, self::META_CONFIRM_TOKEN, self::hashToken( $rawToken ) );
|
||||
update_user_meta(
|
||||
$userId,
|
||||
self::META_CONFIRM_EXPIRES,
|
||||
gmdate( 'Y-m-d H:i:s', time() + self::EMAIL_CONFIRM_EXPIRY_HOURS * 3600 )
|
||||
);
|
||||
|
||||
return $rawToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the account's email confirmed and discard the (now spent) token. The
|
||||
* account stays awaiting approval.
|
||||
*/
|
||||
public static function confirmEmail( int $userId ): void {
|
||||
update_user_meta( $userId, self::META_EMAIL_CONFIRMED, '1' );
|
||||
delete_user_meta( $userId, self::META_CONFIRM_TOKEN );
|
||||
delete_user_meta( $userId, self::META_CONFIRM_EXPIRES );
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve the account: clear the pending flag and any leftover token so the
|
||||
* student becomes a normal, active student.
|
||||
*/
|
||||
public static function approve( int $userId ): void {
|
||||
delete_user_meta( $userId, self::META_AWAITING_APPROVAL );
|
||||
delete_user_meta( $userId, self::META_CONFIRM_TOKEN );
|
||||
delete_user_meta( $userId, self::META_CONFIRM_EXPIRES );
|
||||
}
|
||||
|
||||
public static function isAwaitingApproval( int $userId ): bool {
|
||||
return '1' === Val::string( get_user_meta( $userId, self::META_AWAITING_APPROVAL, true ) );
|
||||
}
|
||||
|
||||
public static function emailConfirmed( int $userId ): bool {
|
||||
return '1' === Val::string( get_user_meta( $userId, self::META_EMAIL_CONFIRMED, true ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the user awaiting confirmation whose stored hash matches the supplied
|
||||
* raw token, or null when none matches.
|
||||
*/
|
||||
public static function userIdForToken( string $rawToken ): ?int {
|
||||
if ( '' === $rawToken ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$users = get_users(
|
||||
[
|
||||
'meta_key' => 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;
|
||||
}
|
||||
}
|
||||
@@ -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 '<p>' . esc_html__( 'Your account is awaiting studio approval. You will be able to book once a studio admin approves it.', 'unsupervised-schedular' ) . '</p>';
|
||||
}
|
||||
|
||||
if ( ! current_user_can( RoleManager::CAP_BOOK_LESSON ) ) {
|
||||
return '<p>' . esc_html__( 'This page is for students only.', 'unsupervised-schedular' ) . '</p>';
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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();
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
use Unsupervised\Schedular\Auth\RegistrationApprovalController;
|
||||
|
||||
/**
|
||||
* @var array<int, \WP_User> $awaitingApproval Confirmed, awaiting a decision.
|
||||
* @var array<int, \WP_User> $awaitingConfirmation Not yet confirmed their email.
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Pending Students', 'unsupervised-schedular'); ?></h1>
|
||||
|
||||
<div class="notice notice-info inline">
|
||||
<p>
|
||||
<?php esc_html_e('Students who signed themselves up appear here. Once they confirm their email you can approve them into a full student account, or reject the application to remove it.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2><?php esc_html_e('Awaiting approval', 'unsupervised-schedular'); ?></h2>
|
||||
<?php if (empty($awaitingApproval)) : ?>
|
||||
<p><?php esc_html_e('No students are awaiting approval.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="widefat striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Name', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($awaitingApproval as $user) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html((string) $user->display_name); ?></td>
|
||||
<td><?php echo esc_html((string) $user->user_email); ?></td>
|
||||
<td><?php echo esc_html((string) $user->user_registered); ?></td>
|
||||
<td>
|
||||
<form method="post" style="display:inline">
|
||||
<?php wp_nonce_field(RegistrationApprovalController::NONCE_ACTION); ?>
|
||||
<input type="hidden" name="user_id" value="<?php echo esc_attr((string) $user->ID); ?>">
|
||||
<button type="submit" name="usc_action" value="approve" class="button button-primary"><?php esc_html_e('Approve', 'unsupervised-schedular'); ?></button>
|
||||
</form>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('<?php echo esc_js(__('Reject and delete this application?', 'unsupervised-schedular')); ?>');">
|
||||
<?php wp_nonce_field(RegistrationApprovalController::NONCE_ACTION); ?>
|
||||
<input type="hidden" name="user_id" value="<?php echo esc_attr((string) $user->ID); ?>">
|
||||
<button type="submit" name="usc_action" value="reject" class="button button-link-delete"><?php esc_html_e('Reject', 'unsupervised-schedular'); ?></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php esc_html_e('Awaiting email confirmation', 'unsupervised-schedular'); ?></h2>
|
||||
<?php if (empty($awaitingConfirmation)) : ?>
|
||||
<p><?php esc_html_e('No students are awaiting email confirmation.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="widefat striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Name', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($awaitingConfirmation as $user) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html((string) $user->display_name); ?></td>
|
||||
<td><?php echo esc_html((string) $user->user_email); ?></td>
|
||||
<td><?php echo esc_html((string) $user->user_registered); ?></td>
|
||||
<td>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('<?php echo esc_js(__('Reject and delete this application?', 'unsupervised-schedular')); ?>');">
|
||||
<?php wp_nonce_field(RegistrationApprovalController::NONCE_ACTION); ?>
|
||||
<input type="hidden" name="user_id" value="<?php echo esc_attr((string) $user->ID); ?>">
|
||||
<button type="submit" name="usc_action" value="reject" class="button button-link-delete"><?php esc_html_e('Reject', 'unsupervised-schedular'); ?></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -15,6 +15,7 @@ if (! defined('ABSPATH')) {
|
||||
* @var string $etransferEmail
|
||||
* @var float $hstRate
|
||||
* @var bool $stripeConfigured
|
||||
* @var bool $openRegistration
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
@@ -88,6 +89,27 @@ if (! defined('ABSPATH')) {
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e('Registration', 'unsupervised-schedular'); ?></h2>
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Student sign-up', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<fieldset>
|
||||
<label>
|
||||
<input type="checkbox" name="open_registration" value="1" <?php checked($openRegistration); ?>>
|
||||
<?php esc_html_e('Allow anyone to sign up (email confirmation + admin approval)', 'unsupervised-schedular'); ?>
|
||||
</label>
|
||||
<p class="description">
|
||||
<?php esc_html_e('Off by default — students join by invite only. When on, anyone can register on the student registration page; each new account must confirm its email and then be approved by a studio admin under Students → Pending Students before it can be used.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
<p class="description">
|
||||
<?php esc_html_e('Turning this on also switches on Settings → General → “Anyone can register” and sets the default new-user role to Student. Turning it off restores those two settings to what they were before.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
</fieldset>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php submit_button(esc_html__('Save Settings', 'unsupervised-schedular')); ?>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
||||
*/
|
||||
?>
|
||||
<div class="us-register-form">
|
||||
<?php if ($success) : ?>
|
||||
<?php if ($successType === 'invite') : ?>
|
||||
<p class="us-success"><?php esc_html_e('Your account has been created and you are now logged in.', 'unsupervised-schedular'); ?></p>
|
||||
<?php elseif (! $canRegister) : ?>
|
||||
<p><?php esc_html_e('Registration is by invitation only. Please use the link from your invitation email, or contact the studio.', 'unsupervised-schedular'); ?></p>
|
||||
<?php elseif ($successType === 'confirm') : ?>
|
||||
<p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, a studio admin will review and approve your account.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<?php if ($error !== '') : ?>
|
||||
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
|
||||
<?php if ($confirmResult === '1') : ?>
|
||||
<p class="us-success"><?php esc_html_e('Thanks — your email is confirmed. Your account is now awaiting studio approval; we will email you when it is ready.', 'unsupervised-schedular'); ?></p>
|
||||
<?php elseif ($confirmResult === 'expired') : ?>
|
||||
<p class="us-error" role="alert"><?php esc_html_e('That confirmation link is invalid or has expired. Please contact the studio.', 'unsupervised-schedular'); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="">
|
||||
<?php wp_nonce_field('us_student_register'); ?>
|
||||
<input type="hidden" name="us_invite" value="<?php echo esc_attr($token); ?>">
|
||||
|
||||
<p>
|
||||
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
|
||||
<input type="email" id="us-reg-email" value="<?php echo esc_attr($invite->email); ?>" readonly>
|
||||
</p>
|
||||
<p>
|
||||
<label for="us-reg-name"><?php esc_html_e('Your name', 'unsupervised-schedular'); ?></label>
|
||||
<input type="text" name="display_name" id="us-reg-name" autocomplete="name" required>
|
||||
</p>
|
||||
<p>
|
||||
<label for="us-reg-pass"><?php esc_html_e('Password', 'unsupervised-schedular'); ?></label>
|
||||
<input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="8" required>
|
||||
</p>
|
||||
|
||||
<?php if (! empty($policyForms)) : ?>
|
||||
<fieldset class="us-policies">
|
||||
<legend><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></legend>
|
||||
<?php foreach ($policyForms as $form) : ?>
|
||||
<div class="us-policy">
|
||||
<h4><?php echo esc_html($form['policy']->title); ?></h4>
|
||||
<div class="us-policy-body"><?php echo wp_kses_post((string) $form['version']->body); ?></div>
|
||||
<label>
|
||||
<input type="checkbox" name="accept[]" value="<?php echo esc_attr((string) $form['version']->id); ?>" required>
|
||||
<?php
|
||||
/* translators: %s: policy title */
|
||||
echo esc_html(sprintf(__('I have read and agree to the %s.', 'unsupervised-schedular'), $form['policy']->title));
|
||||
?>
|
||||
</label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</fieldset>
|
||||
<?php if (! $canRegister) : ?>
|
||||
<p><?php esc_html_e('Registration is by invitation only. Please use the link from your invitation email, or contact the studio.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<?php if ($error !== '') : ?>
|
||||
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<p>
|
||||
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
|
||||
</p>
|
||||
</form>
|
||||
<form method="post" action="">
|
||||
<?php wp_nonce_field('us_student_register'); ?>
|
||||
<input type="hidden" name="us_invite" value="<?php echo esc_attr($token); ?>">
|
||||
|
||||
<p>
|
||||
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
|
||||
<?php if ($invite !== null) : ?>
|
||||
<input type="email" id="us-reg-email" value="<?php echo esc_attr($invite->email); ?>" readonly>
|
||||
<?php else : ?>
|
||||
<input type="email" name="email" id="us-reg-email" autocomplete="email" required>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<p>
|
||||
<label for="us-reg-name"><?php esc_html_e('Your name', 'unsupervised-schedular'); ?></label>
|
||||
<input type="text" name="display_name" id="us-reg-name" autocomplete="name" required>
|
||||
</p>
|
||||
<p>
|
||||
<label for="us-reg-pass"><?php esc_html_e('Password', 'unsupervised-schedular'); ?></label>
|
||||
<input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="8" required>
|
||||
</p>
|
||||
|
||||
<?php if (! empty($policyForms)) : ?>
|
||||
<fieldset class="us-policies">
|
||||
<legend><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></legend>
|
||||
<?php foreach ($policyForms as $form) : ?>
|
||||
<div class="us-policy">
|
||||
<h4><?php echo esc_html($form['policy']->title); ?></h4>
|
||||
<div class="us-policy-body"><?php echo wp_kses_post((string) $form['version']->body); ?></div>
|
||||
<label>
|
||||
<input type="checkbox" name="accept[]" value="<?php echo esc_attr((string) $form['version']->id); ?>" required>
|
||||
<?php
|
||||
/* translators: %s: policy title */
|
||||
echo esc_html(sprintf(__('I have read and agree to the %s.', 'unsupervised-schedular'), $form['policy']->title));
|
||||
?>
|
||||
</label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</fieldset>
|
||||
<?php endif; ?>
|
||||
|
||||
<p>
|
||||
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
|
||||
</p>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Unsupervised\Schedular\Auth\EmailConfirmationHandler;
|
||||
use Unsupervised\Schedular\Auth\RegistrationController;
|
||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class EmailConfirmationHandlerTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\when('wp_unslash')->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\RegistrationApprovalController;
|
||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class RegistrationApprovalControllerTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\when('wp_unslash')->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 = '[email protected]';
|
||||
Functions\when('get_user_by')->justReturn($user);
|
||||
|
||||
$mailer = Mockery::mock(RegistrationMailer::class);
|
||||
$mailer->shouldReceive('sendRejected')->once()->with('[email protected]');
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\RegistrationLoginGate;
|
||||
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class RegistrationLoginGateTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Stub get_user_meta to describe a single account's pending state.
|
||||
*/
|
||||
private function stubMeta(string $awaiting, string $confirmed): void
|
||||
{
|
||||
Functions\when('get_user_meta')->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class RegistrationMailerTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\when('get_bloginfo')->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(
|
||||
'[email protected]',
|
||||
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('[email protected]'), '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' ? '[email protected]' : '');
|
||||
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with('[email protected]', Mockery::type('string'), Mockery::type('string'))
|
||||
->andReturn(true);
|
||||
|
||||
self::assertTrue((new RegistrationMailer())->notifyAdminsPending($this->user('[email protected]')));
|
||||
}
|
||||
|
||||
public function testSendApprovedEmailsTheStudent(): void
|
||||
{
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with('[email protected]', Mockery::type('string'), Mockery::type('string'))
|
||||
->andReturn(true);
|
||||
|
||||
self::assertTrue((new RegistrationMailer())->sendApproved($this->user('[email protected]')));
|
||||
}
|
||||
|
||||
public function testSendRejectedEmailsTheAddress(): void
|
||||
{
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with('[email protected]', Mockery::type('string'), Mockery::type('string'))
|
||||
->andReturn(true);
|
||||
|
||||
self::assertTrue((new RegistrationMailer())->sendRejected('[email protected]'));
|
||||
}
|
||||
|
||||
public function testSendRejectedReturnsFalseWithoutRecipient(): void
|
||||
{
|
||||
self::assertFalse((new RegistrationMailer())->sendRejected(''));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\Invite;
|
||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersion;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class RegistrationPageTest extends TestCase
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
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: '[email protected]', token: 'hash');
|
||||
|
||||
self::assertSame('invite', $this->submit($invite, false));
|
||||
}
|
||||
|
||||
public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void
|
||||
{
|
||||
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
||||
|
||||
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' => '[email protected]' ];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class RegistrationStatusTest extends TestCase
|
||||
{
|
||||
public function testMarkPendingIssuesHashedTokenAndSetsFlags(): void
|
||||
{
|
||||
Functions\when('wp_generate_password')->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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Payment;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class StudioSettingsTest extends TestCase
|
||||
{
|
||||
public function testRegistrationModeDefaultsToInvite(): void
|
||||
{
|
||||
Functions\when('get_option')->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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user