Merge pull request 'Fix invite sign-in, add customizable invite-only text, repair account questions' (#109) from fix/registration-signin-and-account-questions into main
CI / Tests (PHP 8.1) (push) Successful in 40s
CI / Tests (PHP 8.2) (push) Successful in 1m5s
CI / No Debug Code (push) Successful in 3s
CI / Coding Standards (push) Successful in 2m53s
CI / PHPStan (push) Successful in 2m52s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
Release / Build and Publish Release (push) Successful in 3m1s
Release / Open next-version bump PR (push) Successful in 4s
CI / Build Plugin Zip (push) Successful in 2m47s
CI / Tests (PHP 8.1) (push) Successful in 40s
CI / Tests (PHP 8.2) (push) Successful in 1m5s
CI / No Debug Code (push) Successful in 3s
CI / Coding Standards (push) Successful in 2m53s
CI / PHPStan (push) Successful in 2m52s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
Release / Build and Publish Release (push) Successful in 3m1s
Release / Open next-version bump PR (push) Successful in 4s
CI / Build Plugin Zip (push) Successful in 2m47s
Reviewed-on: #109
This commit was merged in pull request #109.
This commit is contained in:
@@ -21,10 +21,15 @@ each change under the current top section as you work.
|
|||||||
- The **Add/Edit Offering** form now shows only the fields relevant to the selected kind: the group-class settings (capacity, dates, times, enrolment/withdrawal deadlines, sessions, schedule note, invite-only) appear only for a group class, and the weekly-reservation option only for a private lesson.
|
- The **Add/Edit Offering** form now shows only the fields relevant to the selected kind: the group-class settings (capacity, dates, times, enrolment/withdrawal deadlines, sessions, schedule note, invite-only) appear only for a group class, and the weekly-reservation option only for a private lesson.
|
||||||
- Instructors can add students to any group class by hand from its details page (**Add students directly**), which now appears for public classes too, not just invite-only ones. This bypasses the enrolment deadline and capacity, so a student can be enrolled as a **late enrolment** after the class has closed to self-enrolment.
|
- Instructors can add students to any group class by hand from its details page (**Add students directly**), which now appears for public classes too, not just invite-only ones. This bypasses the enrolment deadline and capacity, so a student can be enrolled as a **late enrolment** after the class has closed to self-enrolment.
|
||||||
- Studio admins and instructors can open a **lesson detail view** from the Scheduler and My Lessons lists, showing the offering booked, the policy versions the student accepted (with acceptance time and IP), and their intake answers. On My Lessons an instructor may only open their own lessons; the studio Scheduler may open any.
|
- Studio admins and instructors can open a **lesson detail view** from the Scheduler and My Lessons lists, showing the offering booked, the policy versions the student accepted (with acceptance time and IP), and their intake answers. On My Lessons an instructor may only open their own lessons; the studio Scheduler may open any.
|
||||||
|
- The **Student Registration** block's "registration is by invitation only" message is now customisable, under a new **Invitation-only notice** panel (shortcode: `invite_only_message`). Leaving it blank keeps the default wording.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- The student **upcoming lessons** panel now shows each booked offering's name and length beside the time, and lists only the soonest five lessons with a "Show all" reveal. The Scheduler and My Lessons week/list views likewise show the booked offering.
|
- The student **upcoming lessons** panel now shows each booked offering's name and length beside the time, and lists only the soonest five lessons with a "Show all" reveal. The Scheduler and My Lessons week/list views likewise show the booked offering.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Accepting an invitation now keeps the student signed in. Previously the registration form processed the submission after the page had started rendering, so the sign-in cookie was never sent and the new student was bounced back to the (logged-out) registration page; it is now handled before any output, and the student lands logged in.
|
||||||
|
- Account-registration questions now save. On sites first installed before account-scope questions existed, the `us_questions.offering_id` column was left `NOT NULL` (the schema migration relied on `dbDelta`, which does not reliably relax a column to allow `NULL`), so saving an account question failed with "Column 'offering_id' cannot be null". A one-time, self-healing migration relaxes the column on the next load.
|
||||||
|
|
||||||
## [1.1.1]
|
## [1.1.1]
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+16
-3
@@ -5,7 +5,7 @@
|
|||||||
const { registerBlockType } = wp.blocks;
|
const { registerBlockType } = wp.blocks;
|
||||||
const { createElement: el, useState, useEffect } = wp.element;
|
const { createElement: el, useState, useEffect } = wp.element;
|
||||||
const { useBlockProps, InspectorControls } = wp.blockEditor;
|
const { useBlockProps, InspectorControls } = wp.blockEditor;
|
||||||
const { PanelBody, SelectControl, ToggleControl } = wp.components;
|
const { PanelBody, SelectControl, ToggleControl, TextareaControl } = wp.components;
|
||||||
const { useSelect } = wp.data;
|
const { useSelect } = wp.data;
|
||||||
const apiFetch = wp.apiFetch;
|
const apiFetch = wp.apiFetch;
|
||||||
const ServerSideRender = wp.serverSideRender;
|
const ServerSideRender = wp.serverSideRender;
|
||||||
@@ -151,10 +151,12 @@
|
|||||||
shortcode: 'us_student_register',
|
shortcode: 'us_student_register',
|
||||||
attributes: {
|
attributes: {
|
||||||
loginPageId: { type: 'number', default: 0 },
|
loginPageId: { type: 'number', default: 0 },
|
||||||
|
inviteOnlyMessage: { type: 'string', default: '' },
|
||||||
},
|
},
|
||||||
inspector: (attributes, setAttributes) => el(
|
inspector: (attributes, setAttributes) => [
|
||||||
|
el(
|
||||||
PanelBody,
|
PanelBody,
|
||||||
{ title: __('After email confirmation', 'unsupervised-schedular') },
|
{ title: __('After email confirmation', 'unsupervised-schedular'), key: 'confirmation' },
|
||||||
el(PageSelect, {
|
el(PageSelect, {
|
||||||
label: __('Sign-in page', 'unsupervised-schedular'),
|
label: __('Sign-in page', 'unsupervised-schedular'),
|
||||||
help: __('Where the sign-in link shown after a student confirms their email address sends them.', 'unsupervised-schedular'),
|
help: __('Where the sign-in link shown after a student confirms their email address sends them.', 'unsupervised-schedular'),
|
||||||
@@ -163,6 +165,17 @@
|
|||||||
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
el(
|
||||||
|
PanelBody,
|
||||||
|
{ title: __('Invitation-only notice', 'unsupervised-schedular'), key: 'invite-only' },
|
||||||
|
el(TextareaControl, {
|
||||||
|
label: __('Message', 'unsupervised-schedular'),
|
||||||
|
help: __('Shown when registration is invite-only and the visitor has no valid invite link. Leave blank to use the default wording.', 'unsupervised-schedular'),
|
||||||
|
value: attributes.inviteOnlyMessage,
|
||||||
|
onChange: (inviteOnlyMessage) => setAttributes({ inviteOnlyMessage }),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'us-scheduler/group-classes',
|
name: 'us-scheduler/group-classes',
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
|
|||||||
1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created storing the token's SHA-256 hash, and the registration link (with the raw token) is shown **once** in a notice. To re-send a lost link, revoke and re-invite.
|
1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created storing the token's SHA-256 hash, and the registration link (with the raw token) is shown **once** in a notice. To re-send a lost link, revoke and re-invite.
|
||||||
2. The invitee opens `[us_student_register]` with the token (`?us_invite=<token>`); the lookup hashes the submitted token and matches it against the stored hash.
|
2. The invitee opens `[us_student_register]` with the token (`?us_invite=<token>`); the lookup hashes the submitted token and matches it against the stored hash.
|
||||||
3. The form shows the invited email **pre-filled and read-only** (the server always uses the invite's address on submit, so a tampered value is ignored) and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. A token that is no longer redeemable (expired / accepted / revoked) renders the normal editable email field instead when open registration is on.
|
3. The form shows the invited email **pre-filled and read-only** (the server always uses the invite's address on submit, so a tampered value is ignored) and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. A token that is no longer redeemable (expired / accepted / revoked) renders the normal editable email field instead when open registration is on.
|
||||||
4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. If the invite carries an `offering_id` (a group-class email invite), the new account is linked to the matching access grant so the invite-only class becomes enrollable for them — see `group-classes.md`.
|
4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. The submission is processed on `template_redirect` (`RegistrationPage::maybeHandleSubmit()`) **before** any page output so `wp_set_auth_cookie()` actually persists — it then post/redirect/gets back to the page with `?us_registered=invite`, where the now-logged-in student sees the "created and logged in" confirmation. (Processing the form inside `render()`, which runs during `the_content`, sent the cookie after headers and left the student logged out on the next view.) If the invite carries an `offering_id` (a group-class email invite), the new account is linked to the matching access grant so the invite-only class becomes enrollable for them — see `group-classes.md`.
|
||||||
|
|
||||||
## Flow (self-approval mode)
|
## Flow (self-approval mode)
|
||||||
1. Studio admin enables **Studio Settings → Registration** and selects the registration page (shared with invites, `us_registration_page_id`).
|
1. Studio admin enables **Studio Settings → Registration** and selects the registration page (shared with invites, `us_registration_page_id`).
|
||||||
@@ -126,6 +126,7 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
|
|||||||
|
|
||||||
## Frontend Shortcode
|
## Frontend Shortcode
|
||||||
- `[us_student_register]` — the registration page. In `invite` mode: shows the form for a valid pending invite, else an "by invitation only" message. In `self_approval` mode: shows the form to anyone (editable email), and renders confirmation-result notices from `?us_confirmed=1|expired`.
|
- `[us_student_register]` — the registration page. In `invite` mode: shows the form for a valid pending invite, else an "by invitation only" message. In `self_approval` mode: shows the form to anyone (editable email), and renders confirmation-result notices from `?us_confirmed=1|expired`.
|
||||||
|
- The invitation-only message is customisable: block attribute `inviteOnlyMessage` (set under the block's **Invitation-only notice** panel) / shortcode attribute `invite_only_message`. Blank falls back to the default wording (`RegistrationPage::inviteOnlyMessage()`).
|
||||||
|
|
||||||
## Token Redirect
|
## Token Redirect
|
||||||
A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`)
|
A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`)
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
|
|||||||
- Signup step two: `Unsupervised\Schedular\Auth\RegistrationPage`, `templates/frontend/register-page.php`, `assets/js/register.js`
|
- Signup step two: `Unsupervised\Schedular\Auth\RegistrationPage`, `templates/frontend/register-page.php`, `assets/js/register.js`
|
||||||
- Admin review: `Unsupervised\Schedular\Auth\StudentHistory::registrationInfo()`, `templates/admin/student-detail.php`
|
- Admin review: `Unsupervised\Schedular\Auth\StudentHistory::registrationInfo()`, `templates/admin/student-detail.php`
|
||||||
- Schema: `us_questions.scope` + nullable `us_questions.offering_id` (requires a plugin version bump so `dbDelta` runs)
|
- Schema: `us_questions.scope` + nullable `us_questions.offering_id` (requires a plugin version bump so `dbDelta` runs)
|
||||||
|
- Nullability repair: `dbDelta` does **not** reliably relax a column from `NOT NULL` to `NULL`, so sites created before account-scope questions kept `offering_id NOT NULL` and rejected account inserts. `QuestionRepository::ensureOfferingNullable()` re-applies the nullable definition (idempotent `ALTER … MODIFY`); `Plugin::boot()` runs it once, guarded by the `us_questions_offering_nullable` option rather than the version gate (affected sites may already be on the current version)
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
- `tests/Unit/Registration/QuestionRepositoryTest.php`
|
- `tests/Unit/Registration/QuestionRepositoryTest.php`
|
||||||
|
|||||||
+102
-14
@@ -30,6 +30,13 @@ class RegistrationPage {
|
|||||||
*/
|
*/
|
||||||
private const RESULT_CONFIRM_GROUP = 'confirm_group';
|
private const RESULT_CONFIRM_GROUP = 'confirm_group';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation error from the most recent submission processed on
|
||||||
|
* `template_redirect`, carried over to {@see render()} so it can be shown
|
||||||
|
* inline with the form. Empty when the last submit succeeded or none ran.
|
||||||
|
*/
|
||||||
|
private string $submitError = '';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private InviteRepository $invites,
|
private InviteRepository $invites,
|
||||||
private PolicyRepository $policies,
|
private PolicyRepository $policies,
|
||||||
@@ -45,15 +52,29 @@ class RegistrationPage {
|
|||||||
/**
|
/**
|
||||||
* Renders the student registration shortcode output.
|
* Renders the student registration shortcode output.
|
||||||
*
|
*
|
||||||
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
|
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`,
|
||||||
* shortcode attributes (`login_page_id`).
|
* `inviteOnlyMessage`) or shortcode
|
||||||
|
* attributes (`login_page_id`,
|
||||||
|
* `invite_only_message`).
|
||||||
*/
|
*/
|
||||||
public function render( array $atts ): string {
|
public function render( array $atts ): string {
|
||||||
|
// A just-completed invite signup is redirected back here already logged
|
||||||
|
// in (see maybeHandleSubmit); its success flag distinguishes that from a
|
||||||
|
// visitor who simply happens to be signed in already.
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked.
|
||||||
|
$registered = sanitize_key( Val::string( wp_unslash( $_GET['us_registered'] ?? '' ) ) );
|
||||||
|
|
||||||
if ( is_user_logged_in() ) {
|
if ( is_user_logged_in() ) {
|
||||||
|
if ( self::RESULT_INVITE === $registered ) {
|
||||||
|
return '<div class="us-register-form"><p class="us-success">'
|
||||||
|
. esc_html__( 'Your account has been created and you are now logged in.', 'unsupervised-schedular' )
|
||||||
|
. '</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
return '<p>' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '</p>';
|
return '<p>' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '</p>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked below.
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked in maybeHandleSubmit.
|
||||||
$token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) );
|
$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.
|
// Only the token's hash is stored, so hash the submitted token for lookup.
|
||||||
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
|
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
|
||||||
@@ -65,17 +86,12 @@ class RegistrationPage {
|
|||||||
// fail to submit — the stale invite's address.
|
// fail to submit — the stale invite's address.
|
||||||
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
|
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
|
||||||
|
|
||||||
$error = '';
|
// The submission itself is processed in maybeHandleSubmit on
|
||||||
$successType = '';
|
// template_redirect (before any output), so the invite auto-login cookie
|
||||||
|
// is actually sent. Its success signal returns here as ?us_registered;
|
||||||
if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) {
|
// only a validation error is carried on the instance to show inline.
|
||||||
$result = $this->handleSubmit( $invite, $open );
|
$successType = in_array( $registered, [ self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ? $registered : '';
|
||||||
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ) {
|
$error = $this->submitError;
|
||||||
$successType = $result;
|
|
||||||
} else {
|
|
||||||
$error = $result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result of an email-confirmation link (set by EmailConfirmationHandler's redirect).
|
// 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.
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, not a state change.
|
||||||
@@ -87,6 +103,7 @@ class RegistrationPage {
|
|||||||
$policyForms = $this->signupPolicies();
|
$policyForms = $this->signupPolicies();
|
||||||
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||||
$canRegister = $open || $inviteValid;
|
$canRegister = $open || $inviteValid;
|
||||||
|
$inviteOnlyMessage = $this->inviteOnlyMessage( $atts );
|
||||||
|
|
||||||
// The two-step script only matters when there is a second step to reveal.
|
// The two-step script only matters when there is a second step to reveal.
|
||||||
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
|
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
|
||||||
@@ -98,6 +115,77 @@ class RegistrationPage {
|
|||||||
return (string) ob_get_clean();
|
return (string) ob_get_clean();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a submitted registration on `template_redirect`, before any page
|
||||||
|
* output. Running here (rather than inside {@see render()}, which fires
|
||||||
|
* during `the_content` after headers are sent) is what lets the invite
|
||||||
|
* branch's `wp_set_auth_cookie()` actually persist — otherwise the student
|
||||||
|
* appears logged in for a single render and is logged out on the next view.
|
||||||
|
*
|
||||||
|
* On success the request is redirected (post/redirect/get) with a
|
||||||
|
* `?us_registered` flag so a refresh cannot resubmit; a validation error is
|
||||||
|
* stashed for {@see render()} to show inline with the form.
|
||||||
|
*/
|
||||||
|
public function maybeHandleSubmit(): void {
|
||||||
|
if ( ! isset( $_POST['us_register'] ) || is_user_logged_in() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! check_admin_referer( 'us_student_register' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer above.
|
||||||
|
$token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) );
|
||||||
|
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
|
||||||
|
$open = $this->settings->openRegistrationEnabled();
|
||||||
|
|
||||||
|
$result = $this->handleSubmit( $invite, $open );
|
||||||
|
|
||||||
|
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ) {
|
||||||
|
$this->redirect( add_query_arg( 'us_registered', $result, $this->currentUrl() ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->submitError = $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current page's clean permalink, used as the post/redirect/get target
|
||||||
|
* so the invite token and any stale flags are dropped from the URL.
|
||||||
|
*/
|
||||||
|
private function currentUrl(): string {
|
||||||
|
$url = get_permalink();
|
||||||
|
|
||||||
|
return is_string( $url ) ? $url : home_url( '/' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues the post-submit redirect and stops the request. Split out so tests
|
||||||
|
* can observe the target without the process exiting.
|
||||||
|
*/
|
||||||
|
protected function redirect( string $url ): void {
|
||||||
|
wp_safe_redirect( $url );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message shown when registration is closed and no valid invite is
|
||||||
|
* present. Studios can override the default via the block
|
||||||
|
* (`inviteOnlyMessage`) or shortcode (`invite_only_message`) attribute.
|
||||||
|
*
|
||||||
|
* @param array<int|string, mixed> $atts
|
||||||
|
*/
|
||||||
|
private function inviteOnlyMessage( array $atts ): string {
|
||||||
|
$custom = trim( Val::string( $atts['inviteOnlyMessage'] ?? $atts['invite_only_message'] ?? '' ) );
|
||||||
|
|
||||||
|
if ( '' !== $custom ) {
|
||||||
|
return $custom;
|
||||||
|
}
|
||||||
|
|
||||||
|
return esc_html__( 'Registration is by invitation only. Please use the link from your invitation email, or contact the studio.', 'unsupervised-schedular' );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redirect to the configured registration page when an invite token lands
|
* Redirect to the configured registration page when an invite token lands
|
||||||
* elsewhere (e.g. a link generated before the page was selected). Hooked on
|
* elsewhere (e.g. a link generated before the page was selected). Hooked on
|
||||||
|
|||||||
@@ -109,6 +109,10 @@ class BlockRegistrar {
|
|||||||
'type' => 'number',
|
'type' => 'number',
|
||||||
'default' => 0,
|
'default' => 0,
|
||||||
],
|
],
|
||||||
|
'inviteOnlyMessage' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'default' => '',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'us-scheduler/group-classes' => [
|
'us-scheduler/group-classes' => [
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ class Plugin {
|
|||||||
$bookings = new BookingRepository( $wpdb );
|
$bookings = new BookingRepository( $wpdb );
|
||||||
$offerings = new OfferingRepository( $wpdb );
|
$offerings = new OfferingRepository( $wpdb );
|
||||||
$questions = new QuestionRepository( $wpdb );
|
$questions = new QuestionRepository( $wpdb );
|
||||||
|
|
||||||
|
// One-time repair for sites where dbDelta left us_questions.offering_id
|
||||||
|
// NOT NULL (it does not reliably relax NULL-ability), which breaks
|
||||||
|
// account-scope registration questions. Guarded by its own flag rather
|
||||||
|
// than the version gate, since affected sites may already be on the
|
||||||
|
// current version. The flag is only set once the ALTER succeeds.
|
||||||
|
if ( '1' !== get_option( 'us_questions_offering_nullable', '' ) && $questions->ensureOfferingNullable() ) {
|
||||||
|
update_option( 'us_questions_offering_nullable', '1' );
|
||||||
|
}
|
||||||
|
|
||||||
$answers = new AnswerRepository( $wpdb );
|
$answers = new AnswerRepository( $wpdb );
|
||||||
$policies = new PolicyRepository( $wpdb );
|
$policies = new PolicyRepository( $wpdb );
|
||||||
$policyVersions = new PolicyVersionRepository( $wpdb );
|
$policyVersions = new PolicyVersionRepository( $wpdb );
|
||||||
|
|||||||
@@ -106,4 +106,26 @@ class QuestionRepository {
|
|||||||
[ '%d' ]
|
[ '%d' ]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relax `offering_id` to allow NULL for account-scope questions (which are
|
||||||
|
* not tied to an offering).
|
||||||
|
*
|
||||||
|
* The account-questions feature (v1.1.0) made the column nullable in the
|
||||||
|
* schema, but dbDelta does not reliably change a column from NOT NULL to
|
||||||
|
* NULL, so sites created before then keep the old NOT NULL column and reject
|
||||||
|
* account-scope inserts with "Column 'offering_id' cannot be null". This
|
||||||
|
* MODIFY is idempotent — re-applying the nullable definition is a no-op.
|
||||||
|
*
|
||||||
|
* @return bool True when the statement ran (or was already applied), false
|
||||||
|
* if it could not be prepared or the query failed.
|
||||||
|
*/
|
||||||
|
public function ensureOfferingNullable(): bool {
|
||||||
|
$sql = $this->db->prepare(
|
||||||
|
'ALTER TABLE %i MODIFY offering_id BIGINT UNSIGNED NULL DEFAULT NULL',
|
||||||
|
$this->table
|
||||||
|
);
|
||||||
|
|
||||||
|
return null !== $sql && false !== $this->db->query( $sql );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ class ShortcodeRegistrar {
|
|||||||
add_shortcode( 'us_student_login', self::shortcode( [ $this->loginPage, 'render' ] ) );
|
add_shortcode( 'us_student_login', self::shortcode( [ $this->loginPage, 'render' ] ) );
|
||||||
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) );
|
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) );
|
||||||
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) );
|
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) );
|
||||||
|
// Process registration submissions before output so the invite branch's
|
||||||
|
// auth cookie is actually sent (render() runs too late, during the_content).
|
||||||
|
add_action( 'template_redirect', [ $this->registrationPage, 'maybeHandleSubmit' ] );
|
||||||
add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] );
|
add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] );
|
||||||
add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
|
add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ if (! defined('ABSPATH')) {
|
|||||||
* @var bool $inviteValid Whether $invite can still be redeemed — only then is the email fixed.
|
* @var bool $inviteValid Whether $invite can still be redeemed — only then is the email fixed.
|
||||||
* @var string $token Raw invite token from the request (only its hash is stored).
|
* @var string $token Raw invite token from the request (only its hash is stored).
|
||||||
* @var bool $canRegister
|
* @var bool $canRegister
|
||||||
|
* @var string $inviteOnlyMessage Text shown when registration is closed and no valid invite is present.
|
||||||
* @var bool $open Whether open (self-approval) registration is enabled.
|
* @var bool $open Whether open (self-approval) registration is enabled.
|
||||||
* @var string $successType '' | 'invite' (created + logged in) | 'confirm' (check email) | 'confirm_group' (check email; auto-approved on confirm).
|
* @var string $successType '' | 'invite' (created + logged in) | 'confirm' (check email) | 'confirm_group' (check email; auto-approved on confirm).
|
||||||
* @var string $confirmResult '' | '1' (email confirmed, awaiting approval) | 'ready' (confirmed + auto-approved) | 'expired'.
|
* @var string $confirmResult '' | '1' (email confirmed, awaiting approval) | 'ready' (confirmed + auto-approved) | 'expired'.
|
||||||
@@ -74,7 +75,7 @@ $renderQuestionField = static function (Question $question): void {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (! $canRegister) : ?>
|
<?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>
|
<p><?php echo esc_html($inviteOnlyMessage); ?></p>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
<?php if ($error !== '') : ?>
|
<?php if ($error !== '') : ?>
|
||||||
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
|
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
|
||||||
|
|||||||
@@ -59,11 +59,14 @@ class RegistrationPageTest extends TestCase
|
|||||||
'settings' => Mockery::mock(StudioSettings::class),
|
'settings' => Mockery::mock(StudioSettings::class),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class);
|
||||||
|
$this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class);
|
||||||
|
|
||||||
$this->ctx['page'] = new RegistrationPage(
|
$this->ctx['page'] = new RegistrationPage(
|
||||||
$invites,
|
$invites,
|
||||||
$policies,
|
$policies,
|
||||||
Mockery::mock(PolicyVersionRepository::class),
|
$this->ctx['versions'],
|
||||||
Mockery::mock(AcceptanceRepository::class),
|
$this->ctx['acceptances'],
|
||||||
$this->ctx['settings'],
|
$this->ctx['settings'],
|
||||||
$this->ctx['mailer'],
|
$this->ctx['mailer'],
|
||||||
$questions,
|
$questions,
|
||||||
@@ -403,4 +406,98 @@ class RegistrationPageTest extends TestCase
|
|||||||
|
|
||||||
self::assertSame('invite', $this->submit($invite, false));
|
self::assertSame('invite', $this->submit($invite, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testMaybeHandleSubmitLogsInInviteAndRedirects(): void
|
||||||
|
{
|
||||||
|
$_POST = [ 'us_register' => '1', 'password' => 'password123', 'display_name' => 'Ada' ];
|
||||||
|
$_REQUEST = [ 'us_invite' => 'raw-token' ];
|
||||||
|
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(false);
|
||||||
|
Functions\when('check_admin_referer')->justReturn(true);
|
||||||
|
Functions\when('email_exists')->justReturn(false);
|
||||||
|
Functions\when('wp_insert_user')->justReturn(42);
|
||||||
|
Functions\when('is_wp_error')->justReturn(false);
|
||||||
|
Functions\when('get_permalink')->justReturn('http://home.test/register/');
|
||||||
|
Functions\when('add_query_arg')->alias(static fn (string $k, string $v, string $u): string => $u . '?' . $k . '=' . $v);
|
||||||
|
|
||||||
|
// The cookie must be set here — during template_redirect, before output —
|
||||||
|
// which is the whole point of processing the submit outside render().
|
||||||
|
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', createdAt: '2024-01-01 00:00:00', id: 9);
|
||||||
|
$this->ctx['invites']->shouldReceive('findByToken')->once()->andReturn($invite);
|
||||||
|
$this->ctx['invites']->shouldReceive('markAccepted')->once();
|
||||||
|
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(false);
|
||||||
|
|
||||||
|
$page = Mockery::mock(
|
||||||
|
RegistrationPage::class,
|
||||||
|
[
|
||||||
|
$this->ctx['invites'],
|
||||||
|
$this->ctx['policies'],
|
||||||
|
$this->ctx['versions'],
|
||||||
|
$this->ctx['acceptances'],
|
||||||
|
$this->ctx['settings'],
|
||||||
|
$this->ctx['mailer'],
|
||||||
|
$this->ctx['questions'],
|
||||||
|
$this->ctx['answers'],
|
||||||
|
$this->ctx['access'],
|
||||||
|
]
|
||||||
|
)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||||
|
|
||||||
|
$captured = '';
|
||||||
|
$page->shouldReceive('redirect')->once()->with(Mockery::on(static function (string $url) use (&$captured): bool {
|
||||||
|
$captured = $url;
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
|
||||||
|
$page->maybeHandleSubmit();
|
||||||
|
|
||||||
|
self::assertStringContainsString('us_registered=invite', $captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMaybeHandleSubmitStoresValidationErrorWithoutRedirecting(): void
|
||||||
|
{
|
||||||
|
// Too-short password: handleSubmit returns an error and no redirect fires.
|
||||||
|
$_POST = [ 'us_register' => '1', 'password' => 'short', 'display_name' => 'Ada' ];
|
||||||
|
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(false);
|
||||||
|
Functions\when('check_admin_referer')->justReturn(true);
|
||||||
|
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(true);
|
||||||
|
|
||||||
|
// A redirect would call exit; reaching the assertion proves none happened.
|
||||||
|
$this->ctx['page']->maybeHandleSubmit();
|
||||||
|
|
||||||
|
$error = (new \ReflectionProperty(RegistrationPage::class, 'submitError'))->getValue($this->ctx['page']);
|
||||||
|
self::assertNotSame('', $error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInviteSuccessRedirectShowsLoggedInWelcome(): void
|
||||||
|
{
|
||||||
|
// After the PRG redirect the student is logged in; the us_registered flag
|
||||||
|
// distinguishes a just-completed signup from an already-logged-in visitor.
|
||||||
|
$_GET = [ 'us_registered' => 'invite' ];
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(true);
|
||||||
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||||
|
|
||||||
|
$html = $this->ctx['page']->render([]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('us-success', $html);
|
||||||
|
self::assertStringContainsString('now logged in', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInviteOnlyMessageCanBeCustomised(): void
|
||||||
|
{
|
||||||
|
// Closed registration and no invite → the invitation-only gate shows.
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(false);
|
||||||
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||||
|
Functions\when('wp_login_url')->justReturn('http://home.test/wp-login.php');
|
||||||
|
Functions\when('wp_nonce_field')->justReturn('');
|
||||||
|
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(false);
|
||||||
|
|
||||||
|
$html = $this->ctx['page']->render([ 'inviteOnlyMessage' => 'Ask the front desk for a link.' ]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('Ask the front desk for a link.', $html);
|
||||||
|
self::assertStringNotContainsString('by invitation only', $html);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ class BlockRegistrarTest extends TestCase
|
|||||||
array_keys($registered['us-scheduler/student-login']['attributes'])
|
array_keys($registered['us-scheduler/student-login']['attributes'])
|
||||||
);
|
);
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['loginPageId'],
|
['loginPageId', 'inviteOnlyMessage'],
|
||||||
array_keys($registered['us-scheduler/student-register']['attributes'])
|
array_keys($registered['us-scheduler/student-register']['attributes'])
|
||||||
);
|
);
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
|
|||||||
@@ -212,4 +212,28 @@ class QuestionRepositoryTest extends TestCase
|
|||||||
|
|
||||||
self::assertTrue($this->repo->delete(4));
|
self::assertTrue($this->repo->delete(4));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testEnsureOfferingNullableRunsAlterAndReportsSuccess(): void
|
||||||
|
{
|
||||||
|
$this->db->shouldReceive('prepare')
|
||||||
|
->once()
|
||||||
|
->with(Mockery::pattern('/ALTER TABLE %i MODIFY offering_id .*NULL/'), 'wp_us_questions')
|
||||||
|
->andReturn('ALTER TABLE `wp_us_questions` MODIFY offering_id BIGINT UNSIGNED NULL DEFAULT NULL');
|
||||||
|
|
||||||
|
$this->db->shouldReceive('query')
|
||||||
|
->once()
|
||||||
|
->with('ALTER TABLE `wp_us_questions` MODIFY offering_id BIGINT UNSIGNED NULL DEFAULT NULL')
|
||||||
|
->andReturn(0);
|
||||||
|
|
||||||
|
// A successful DDL query returns 0 rows affected (not false).
|
||||||
|
self::assertTrue($this->repo->ensureOfferingNullable());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnsureOfferingNullableReportsFailureWhenQueryFails(): void
|
||||||
|
{
|
||||||
|
$this->db->shouldReceive('prepare')->once()->andReturn('ALTER ...');
|
||||||
|
$this->db->shouldReceive('query')->once()->andReturn(false);
|
||||||
|
|
||||||
|
self::assertFalse($this->repo->ensureOfferingNullable());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user