Fix invite sign-in persistence, add invite-only text option, repair account questions
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 3m6s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 3m6s
CI / Build Plugin Zip (pull_request) Skipped
Three registration fixes reported from live use:
- Accepting an invite now keeps the student signed in. The form was
processed inside render() during the_content, so wp_set_auth_cookie()
ran after headers were sent and the cookie never persisted — the new
student was bounced back to the logged-out registration page. The
submission is now handled on template_redirect (before output) with a
post/redirect/get, so the cookie sticks and the student lands logged in.
- The "registration is by invitation only" message is now customisable via
a new block attribute (inviteOnlyMessage / shortcode invite_only_message),
falling back to the default wording when blank.
- Account-registration questions save again. dbDelta does not reliably
relax a column from NOT NULL to NULL, so sites created before account-
scope questions kept us_questions.offering_id NOT NULL and rejected
account inserts ("Column 'offering_id' cannot be null"). A one-time,
self-healing migration (guarded by its own option, not the version gate)
re-applies the nullable definition on next load.
composer test, composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
+105
-17
@@ -30,6 +30,13 @@ class RegistrationPage {
|
||||
*/
|
||||
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(
|
||||
private InviteRepository $invites,
|
||||
private PolicyRepository $policies,
|
||||
@@ -45,15 +52,29 @@ class RegistrationPage {
|
||||
/**
|
||||
* Renders the student registration shortcode output.
|
||||
*
|
||||
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
|
||||
* shortcode attributes (`login_page_id`).
|
||||
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`,
|
||||
* `inviteOnlyMessage`) or shortcode
|
||||
* attributes (`login_page_id`,
|
||||
* `invite_only_message`).
|
||||
*/
|
||||
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 ( 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>';
|
||||
}
|
||||
|
||||
// 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'] ?? '' ) ) );
|
||||
// Only the token's hash is stored, so hash the submitted token for lookup.
|
||||
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
|
||||
@@ -65,17 +86,12 @@ class RegistrationPage {
|
||||
// fail to submit — the stale invite's address.
|
||||
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
|
||||
|
||||
$error = '';
|
||||
$successType = '';
|
||||
|
||||
if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) {
|
||||
$result = $this->handleSubmit( $invite, $open );
|
||||
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ) {
|
||||
$successType = $result;
|
||||
} else {
|
||||
$error = $result;
|
||||
}
|
||||
}
|
||||
// The submission itself is processed in maybeHandleSubmit on
|
||||
// template_redirect (before any output), so the invite auto-login cookie
|
||||
// is actually sent. Its success signal returns here as ?us_registered;
|
||||
// only a validation error is carried on the instance to show inline.
|
||||
$successType = in_array( $registered, [ self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ? $registered : '';
|
||||
$error = $this->submitError;
|
||||
|
||||
// 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.
|
||||
@@ -84,9 +100,10 @@ class RegistrationPage {
|
||||
// Where the post-confirmation prompt sends students to sign in.
|
||||
$loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) );
|
||||
|
||||
$policyForms = $this->signupPolicies();
|
||||
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
$canRegister = $open || $inviteValid;
|
||||
$policyForms = $this->signupPolicies();
|
||||
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
$canRegister = $open || $inviteValid;
|
||||
$inviteOnlyMessage = $this->inviteOnlyMessage( $atts );
|
||||
|
||||
// The two-step script only matters when there is a second step to reveal.
|
||||
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
|
||||
@@ -98,6 +115,77 @@ class RegistrationPage {
|
||||
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
|
||||
* elsewhere (e.g. a link generated before the page was selected). Hooked on
|
||||
|
||||
Reference in New Issue
Block a user