CI / Tests (PHP 8.2) (pull_request) Successful in 58s
CI / Tests (PHP 8.1) (pull_request) Successful in 58s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / PHPStan (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
Every account-signup question was asked of everybody who registered, on the same terms: "school and grade" had to be put to an adult signing themselves up, and a question a studio needed answered for each student could only be made required by demanding it of everyone. A question now carries an audience — everyone, or only the students someone registers on behalf of — and its own required flag for each side, so optional for you and required for every student you enrol is expressible. Both settings are account-scope only: an offering asks its questions once, about the student being booked, so there is no second audience to differ from, and an offering question mirrors its single "required" into both columns. Every caller reads askedOfSelf()/isRequiredForSelf()/isRequiredForChild() rather than the raw flags, so a students-only question can neither block the account holder nor have an answer filed against them by a crafted post. The family screen, which only ever adds a student, is held to the students' rule. is_required_child arrives from dbDelta defaulting to 0, which would quietly stop every existing required question being required of the students a guardian registers — the case it most likely existed for. A one-time backfill copies is_required across, guarded by its own option so a question later made optional for students stays that way. Closes #163 Co-Authored-By: Claude Opus 5 <[email protected]>
798 lines
31 KiB
PHP
798 lines
31 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Auth;
|
|
|
|
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
|
use Unsupervised\Schedular\Payment\StudioSettings;
|
|
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
|
use Unsupervised\Schedular\Policy\Policy;
|
|
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
|
use Unsupervised\Schedular\Policy\PolicyRepository;
|
|
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
|
use Unsupervised\Schedular\Registration\Answer;
|
|
use Unsupervised\Schedular\Registration\AnswerRepository;
|
|
use Unsupervised\Schedular\Registration\Question;
|
|
use Unsupervised\Schedular\Registration\QuestionRepository;
|
|
use Unsupervised\Schedular\Val;
|
|
|
|
class RegistrationPage {
|
|
|
|
/** "Who are you registering?": the account holder, and nobody else. */
|
|
public const FOR_SELF = 'self';
|
|
|
|
/** Only other people — the account holder is not a student. */
|
|
public const FOR_STUDENTS = 'students';
|
|
|
|
/** The account holder *and* other people. */
|
|
public const FOR_BOTH = 'both';
|
|
|
|
/** 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';
|
|
|
|
/**
|
|
* Success signal: a group-link signup was created and must confirm their
|
|
* email — confirming approves the account immediately (no admin review).
|
|
*/
|
|
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,
|
|
private PolicyVersionRepository $versions,
|
|
private AcceptanceRepository $acceptances,
|
|
private StudioSettings $settings,
|
|
private RegistrationMailer $mailer,
|
|
private QuestionRepository $questions,
|
|
private AnswerRepository $answers,
|
|
private GroupAccessRepository $access,
|
|
private GuardianService $guardians,
|
|
) {}
|
|
|
|
/**
|
|
* Renders the student registration shortcode output.
|
|
*
|
|
* @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() ) {
|
|
// Both logged-in outcomes are dead ends without somewhere to go next,
|
|
// so both offer the same "continue" link to the configured page.
|
|
wp_enqueue_style( 'us-scheduler' );
|
|
$link = $this->continueLink( $atts );
|
|
|
|
if ( self::RESULT_INVITE === $registered ) {
|
|
// An invited student is done the moment they land here logged in.
|
|
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>' . $link . '</div>';
|
|
}
|
|
|
|
return '<div class="us-register-form"><p>'
|
|
. esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' )
|
|
. '</p>' . $link . '</div>';
|
|
}
|
|
|
|
// 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;
|
|
$open = $this->settings->openRegistrationEnabled();
|
|
|
|
// Only a redeemable invite fixes the form's email to the invited address.
|
|
// A stale token (expired / accepted / revoked) with open registration on
|
|
// must fall back to the normal editable email field, not show — and then
|
|
// fail to submit — the stale invite's address.
|
|
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
|
|
|
|
// 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.
|
|
$confirmResult = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
|
|
|
|
// Where the post-confirmation prompt sends students to sign in.
|
|
$loginUrl = $this->loginUrl( $this->successPageId( $atts ) );
|
|
|
|
$policyForms = $this->signupPolicies();
|
|
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
|
$canRegister = $open || $inviteValid;
|
|
$inviteOnlyMessage = $this->inviteOnlyMessage( $atts );
|
|
|
|
// The signup form carries the same policy-acceptance markup as the booking
|
|
// gate, so it needs the plugin stylesheet that formats it.
|
|
wp_enqueue_style( 'us-scheduler' );
|
|
|
|
// The script drives the parent/guardian section (revealing it, taking the
|
|
// account holder's own panel out of play, and cloning the child block for
|
|
// "add another") and the password meter, so it is needed whenever the form
|
|
// itself is on screen.
|
|
if ( $canRegister && '' === $successType ) {
|
|
wp_enqueue_script( 'us-scheduler-register' );
|
|
|
|
// The browser gate reads the same numbers the server enforces, so the
|
|
// two cannot drift into disagreeing about what it accepted.
|
|
wp_localize_script(
|
|
'us-scheduler-register',
|
|
'usSchedulerPassword',
|
|
[
|
|
'minLength' => PasswordPolicy::MIN_LENGTH,
|
|
'minScore' => PasswordPolicy::MIN_SCORE,
|
|
'strings' => [
|
|
'short' => sprintf(
|
|
/* translators: %d: minimum number of characters. */
|
|
__( 'At least %d characters, please.', 'unsupervised-schedular' ),
|
|
PasswordPolicy::MIN_LENGTH
|
|
),
|
|
'veryWeak' => __( 'Too weak — a stranger could guess this.', 'unsupervised-schedular' ),
|
|
'weak' => __( 'Still too weak. Try a longer phrase.', 'unsupervised-schedular' ),
|
|
'medium' => __( 'Good enough.', 'unsupervised-schedular' ),
|
|
'strong' => __( 'Strong password.', 'unsupervised-schedular' ),
|
|
],
|
|
]
|
|
);
|
|
}
|
|
|
|
ob_start();
|
|
include USC_PLUGIN_DIR . 'templates/frontend/register-page.php';
|
|
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
|
|
* `template_redirect`.
|
|
*/
|
|
public function maybeRedirectToRegistrationPage(): void {
|
|
if ( is_admin() ) {
|
|
return;
|
|
}
|
|
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only token used only to build the redirect target.
|
|
$token = sanitize_text_field( Val::string( wp_unslash( $_GET['us_invite'] ?? '' ) ) );
|
|
if ( '' === $token ) {
|
|
return;
|
|
}
|
|
|
|
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
|
|
if ( $pageId <= 0 || is_page( $pageId ) ) {
|
|
return;
|
|
}
|
|
|
|
wp_safe_redirect( add_query_arg( 'us_invite', rawurlencode( $token ), (string) get_permalink( $pageId ) ) );
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* 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, 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' );
|
|
}
|
|
|
|
// The submit nonce is verified by the caller (render) before this runs.
|
|
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
|
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passwords must not be sanitized.
|
|
$password = Val::string( wp_unslash( $_POST['password'] ?? '' ) );
|
|
$displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
|
|
|
|
// The email is fixed by a personal invite; group-link signups and
|
|
// self-signups supply their own.
|
|
if ( $inviteValid && ! $invite->isGroup() ) {
|
|
$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' );
|
|
}
|
|
}
|
|
|
|
// After the email, so the password can be checked against it. The browser
|
|
// scores the password with zxcvbn and refuses to submit a weak one, but
|
|
// that is advice a client can decline to take — this is the check that
|
|
// holds. See PasswordPolicy for why the two halves differ.
|
|
$passwordError = PasswordPolicy::validate( $password, $email, $displayName );
|
|
if ( null !== $passwordError ) {
|
|
return esc_html( $passwordError );
|
|
}
|
|
|
|
$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'] ?? [] ) );
|
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
|
|
|
foreach ( $policyForms as $form ) {
|
|
if ( ! in_array( (int) $form['version']->id, $accepted, true ) ) {
|
|
return esc_html__( 'You must accept all required policies to register.', 'unsupervised-schedular' );
|
|
}
|
|
}
|
|
|
|
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
|
|
|
// The account-signup questions describe a *student* — instrument, level,
|
|
// school — not whoever holds the account. So they are asked of each
|
|
// student being added, and of the account holder only when they are a
|
|
// student themselves. "Both" is both.
|
|
$registeringFor = $this->submittedRegisteringFor();
|
|
|
|
// A "students only" question is never put to the account holder, so it is
|
|
// dropped before their answers are validated or stored — a crafted post
|
|
// cannot file one against them.
|
|
$selfQuestions = array_values(
|
|
array_filter( $accountQuestions, static fn( Question $question ): bool => $question->askedOfSelf() )
|
|
);
|
|
|
|
// "Students" and "both" collect student blocks; only "self" does not.
|
|
$isGuardian = self::FOR_SELF !== $registeringFor;
|
|
|
|
// "Self" and "both" make the account holder a student, so they answer the
|
|
// questions in their own right. Only a pure guardian does not.
|
|
$asksSelf = self::FOR_STUDENTS !== $registeringFor;
|
|
|
|
$children = $isGuardian ? $this->submittedChildren() : [];
|
|
$answers = $asksSelf ? $this->submittedAnswers() : [];
|
|
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by the caller.
|
|
$birthYear = $asksSelf ? trim( sanitize_text_field( Val::string( wp_unslash( $_POST['birth_year'] ?? '' ) ) ) ) : '';
|
|
|
|
// Everything is validated before a single user is created, so a bad child
|
|
// block never leaves a half-registered family behind.
|
|
if ( $isGuardian && [] === $children ) {
|
|
return esc_html__( 'Please add at least one student, or choose "Just myself" instead.', 'unsupervised-schedular' );
|
|
}
|
|
|
|
// Name and birth year are required per student, and are checked here for
|
|
// the same reason the questions below are: the child blocks are hidden
|
|
// until the guardian box is ticked, so the browser cannot be asked to
|
|
// enforce them without blocking a signup that has no children at all.
|
|
foreach ( $children as $child ) {
|
|
if ( '' === $child['name'] ) {
|
|
return esc_html__( 'Please give each student a name.', 'unsupervised-schedular' );
|
|
}
|
|
|
|
if ( 0 === GuardianService::normaliseBirthYear( $child['birth_year'] ) ) {
|
|
return esc_html( GuardianService::birthYearError() );
|
|
}
|
|
}
|
|
|
|
// Checked as two passes rather than one so the message can say *whose*
|
|
// answers are missing — under "both" a single message could not.
|
|
foreach ( array_column( $children, 'answers' ) as $set ) {
|
|
if ( $this->hasUnansweredRequired( $accountQuestions, $set, forChild: true ) ) {
|
|
return esc_html__( 'Please answer all required registration questions for each student.', 'unsupervised-schedular' );
|
|
}
|
|
}
|
|
|
|
// The account holder is a student too under "self" and "both", so the same
|
|
// birth year every other student gives is asked of them — and checked
|
|
// here rather than left to the browser, for the same reason as the
|
|
// children's: the panel is hidden for a pure guardian, so `required`
|
|
// alone cannot be trusted to have applied.
|
|
if ( $asksSelf && 0 === GuardianService::normaliseBirthYear( $birthYear ) ) {
|
|
return esc_html( GuardianService::ownBirthYearError() );
|
|
}
|
|
|
|
if ( $asksSelf && $this->hasUnansweredRequired( $selfQuestions, $answers ) ) {
|
|
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
|
|
}
|
|
|
|
if ( email_exists( $email ) ) {
|
|
return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' );
|
|
}
|
|
|
|
// Nickname as well as display name. WordPress defaults nickname to
|
|
// `user_login`, which here is the email address — so without this the
|
|
// account's own address became its nickname, and every screen that names
|
|
// a person through `UserName` showed the address instead of the name they
|
|
// had just typed. `UserName` copes with the accounts already created that
|
|
// way; this stops any more of them.
|
|
$name = '' !== $displayName ? $displayName : $email;
|
|
|
|
$userId = wp_insert_user(
|
|
[
|
|
'user_login' => $email,
|
|
'user_email' => $email,
|
|
'user_pass' => $password,
|
|
'display_name' => $name,
|
|
'nickname' => $name,
|
|
'role' => $inviteValid ? $invite->role : RoleManager::STUDENT,
|
|
]
|
|
);
|
|
|
|
if ( is_wp_error( $userId ) ) {
|
|
return esc_html__( 'Could not create the account. Please contact the studio.', 'unsupervised-schedular' );
|
|
}
|
|
|
|
$this->recordAcceptances( $policyForms, (int) $userId, (int) $userId );
|
|
|
|
// Only "students" means the account holder is not a student themselves;
|
|
// "both" registers them alongside the people they book for.
|
|
$this->guardians->setGuardianOnly( (int) $userId, self::FOR_STUDENTS === $registeringFor );
|
|
|
|
if ( $asksSelf ) {
|
|
$this->guardians->setBirthYear( (int) $userId, $birthYear );
|
|
}
|
|
|
|
if ( $isGuardian ) {
|
|
$failure = $this->createChildren( $children, $accountQuestions, $policyForms, (int) $userId );
|
|
if ( '' !== $failure ) {
|
|
return $failure;
|
|
}
|
|
}
|
|
|
|
// After the children, so a rollback that deletes this account cannot
|
|
// leave its answers behind pointing at a user that no longer exists.
|
|
if ( $asksSelf ) {
|
|
$this->recordAnswers( $selfQuestions, $answers, (int) $userId );
|
|
}
|
|
|
|
if ( $inviteValid && ! $invite->isGroup() ) {
|
|
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
|
|
|
|
// A personal invite may carry a group-class grant (invited by email);
|
|
// point any grants for this address at the new account so the class
|
|
// becomes enrollable for them.
|
|
$this->access->linkStudentByEmail( $email, (int) $userId );
|
|
|
|
wp_set_current_user( (int) $userId );
|
|
wp_set_auth_cookie( (int) $userId );
|
|
|
|
return self::RESULT_INVITE;
|
|
}
|
|
|
|
// Group-link signups and self-signups both stay pending until they
|
|
// confirm their email; the group link is multi-use so it is never marked
|
|
// accepted. A group signup auto-approves on confirmation — no admin
|
|
// review — while a self-signup then waits for studio approval.
|
|
$autoApprove = $inviteValid && $invite->isGroup();
|
|
|
|
$rawToken = RegistrationStatus::markPending( (int) $userId, $autoApprove );
|
|
$user = get_user_by( 'id', (int) $userId );
|
|
if ( $user instanceof \WP_User ) {
|
|
$this->mailer->sendConfirmation( $user, $this->confirmUrl( $rawToken ) );
|
|
}
|
|
|
|
return $autoApprove ? self::RESULT_CONFIRM_GROUP : self::RESULT_CONFIRM;
|
|
}
|
|
|
|
/**
|
|
* The page id chosen for the post-registration destination, from either the
|
|
* block (`loginPageId`) or shortcode (`login_page_id`) attribute.
|
|
*
|
|
* @param array<int|string, mixed> $atts
|
|
*/
|
|
private function successPageId( array $atts ): int {
|
|
return Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
|
|
}
|
|
|
|
/**
|
|
* URL the post-confirmation sign-in link points to: the chosen login page
|
|
* when one is configured (and still exists), otherwise the WordPress login
|
|
* screen.
|
|
*/
|
|
private function loginUrl( int $loginPageId ): string {
|
|
return $this->continueUrl( $loginPageId ) ?? wp_login_url();
|
|
}
|
|
|
|
/**
|
|
* The "continue" paragraph shown to a logged-in visitor, or an empty string
|
|
* when no destination page is configured. The link names the chosen page, so
|
|
* the visitor knows where it goes before clicking; an untitled page falls
|
|
* back to generic wording rather than reading "Continue to ".
|
|
*
|
|
* The sign-in-page fallback {@see loginUrl()} applies is deliberately not
|
|
* used here: pointing someone who is already signed in at the login screen is
|
|
* the same dead end with extra steps, so no link is better than that one.
|
|
*
|
|
* @param array<int|string, mixed> $atts
|
|
*/
|
|
private function continueLink( array $atts ): string {
|
|
$pageId = $this->successPageId( $atts );
|
|
$continue = $this->continueUrl( $pageId );
|
|
|
|
if ( null === $continue ) {
|
|
return '';
|
|
}
|
|
|
|
$title = trim( Val::string( get_the_title( $pageId ) ) );
|
|
$label = '' === $title
|
|
? esc_html__( 'Continue to your account', 'unsupervised-schedular' )
|
|
: esc_html(
|
|
sprintf(
|
|
/* translators: %s: title of the page the student continues to. */
|
|
__( 'Continue to %s', 'unsupervised-schedular' ),
|
|
$title
|
|
)
|
|
);
|
|
|
|
return '<p><a href="' . esc_url( $continue ) . '">' . $label . '</a></p>';
|
|
}
|
|
|
|
/**
|
|
* The chosen post-registration page's URL, or null when none is configured
|
|
* (or it has since been deleted). Unlike {@see loginUrl()} this has no
|
|
* WordPress-login-screen fallback, so callers that need a page the student
|
|
* was actually sent to — the invited-student link and the block's
|
|
* auto-redirect — can tell "not configured" from "configured".
|
|
*/
|
|
public function continueUrl( int $pageId ): ?string {
|
|
if ( $pageId <= 0 ) {
|
|
return null;
|
|
}
|
|
|
|
$url = get_permalink( $pageId );
|
|
|
|
return is_string( $url ) ? $url : null;
|
|
}
|
|
|
|
/**
|
|
* Whether this request is a *finished* registration — the states the
|
|
* block's auto-redirect may act on:
|
|
*
|
|
* - an invited student who just signed up and is now logged in, and
|
|
* - a self-signup returning from the emailed confirmation link, whether
|
|
* their account is ready (`ready`) or awaiting studio approval (`1`).
|
|
*
|
|
* Deliberately excluded: the intermediate "check your email" step (the
|
|
* student would never see the instruction) and every failure — a validation
|
|
* error or an expired confirmation link (`expired`) — so the message always
|
|
* gets shown. The `us_confirmed` values are set by
|
|
* {@see EmailConfirmationHandler::maybeConfirm()}.
|
|
*/
|
|
public function isRegistrationComplete(): bool {
|
|
// 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 ( self::RESULT_INVITE === $registered ) {
|
|
return is_user_logged_in();
|
|
}
|
|
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag set by EmailConfirmationHandler's redirect.
|
|
$confirmed = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
|
|
|
|
return in_array( $confirmed, [ '1', 'ready' ], true );
|
|
}
|
|
|
|
/**
|
|
* 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 );
|
|
}
|
|
|
|
/**
|
|
* Whether any required question in `$questions` is left blank in `$answers`.
|
|
*
|
|
* `$forChild` picks which required-ness applies: a question can be optional
|
|
* for the account holder answering about themselves and still required of
|
|
* every student they register.
|
|
*
|
|
* @param list<Question> $questions
|
|
* @param array<int, string> $answers
|
|
*/
|
|
private function hasUnansweredRequired( array $questions, array $answers, bool $forChild = false ): bool {
|
|
foreach ( $questions as $question ) {
|
|
$required = $forChild ? $question->isRequiredForChild() : $question->isRequiredForSelf();
|
|
|
|
if ( $required && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Who this signup is for: {@see FOR_SELF}, {@see FOR_STUDENTS} or
|
|
* {@see FOR_BOTH}.
|
|
*
|
|
* Anything unrecognised — including a form posted without the field at all —
|
|
* falls back to "just myself", the choice that collects the least and grants
|
|
* the least. A missing radio must not be read as "register these children".
|
|
*/
|
|
private function submittedRegisteringFor(): string {
|
|
// The submit nonce is verified by the caller before this runs.
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
|
$value = sanitize_key( Val::string( wp_unslash( $_POST['us_registering_for'] ?? '' ) ) );
|
|
|
|
return in_array( $value, [ self::FOR_STUDENTS, self::FOR_BOTH ], true ) ? $value : self::FOR_SELF;
|
|
}
|
|
|
|
/**
|
|
* The child blocks submitted with a guardian signup, as
|
|
* `children[<n>][name|birth_year|answers]`.
|
|
*
|
|
* An **entirely empty** block is dropped rather than rejected — the form always
|
|
* renders one spare for "add another", and an untouched spare is not a mistake
|
|
* the guardian needs telling about. A block with anything at all filled in is
|
|
* kept, so {@see handleSubmit()} can reject it for the missing name or birth
|
|
* year rather than silently discarding what they typed.
|
|
*
|
|
* @return list<array{name: string, birth_year: string, answers: array<int, string>}>
|
|
*/
|
|
private function submittedChildren(): array {
|
|
// The submit nonce is verified by the caller before this runs.
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each field is unslashed and sanitized below.
|
|
$raw = $_POST['children'] ?? [];
|
|
if ( ! is_array( $raw ) ) {
|
|
return [];
|
|
}
|
|
|
|
$out = [];
|
|
foreach ( $raw as $child ) {
|
|
if ( ! is_array( $child ) ) {
|
|
continue;
|
|
}
|
|
|
|
$name = trim( sanitize_text_field( Val::string( wp_unslash( $child['name'] ?? '' ) ) ) );
|
|
$birthYear = trim( sanitize_text_field( Val::string( wp_unslash( $child['birth_year'] ?? '' ) ) ) );
|
|
|
|
$answers = [];
|
|
foreach ( (array) ( $child['answers'] ?? [] ) as $questionId => $value ) {
|
|
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
|
|
}
|
|
|
|
if ( '' === $name && '' === $birthYear && '' === trim( implode( '', $answers ) ) ) {
|
|
continue;
|
|
}
|
|
|
|
$out[] = [
|
|
'name' => $name,
|
|
'birth_year' => $birthYear,
|
|
'answers' => $answers,
|
|
];
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Create each child of a guardian signup: the login-less account, its answers
|
|
* to the per-child questions, and a signup-policy acceptance recorded against
|
|
* the child but attributed to the guardian who agreed for them.
|
|
*
|
|
* Returns an empty string on success, or an error message after rolling the
|
|
* whole family back — every child created so far *and* the guardian. A signup
|
|
* that half-worked would leave the guardian with an account they cannot
|
|
* re-register and children they never confirmed, so it is undone entirely and
|
|
* they simply try again.
|
|
*
|
|
* @param list<array{name: string, birth_year: string, answers: array<int, string>}> $children
|
|
* @param list<Question> $questions
|
|
* @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
|
*/
|
|
private function createChildren( array $children, array $questions, array $policyForms, int $guardianId ): string {
|
|
$created = [];
|
|
|
|
foreach ( $children as $child ) {
|
|
$childId = $this->guardians->createChild( $guardianId, $child['name'], $child['birth_year'] );
|
|
|
|
if ( $childId instanceof \WP_Error ) {
|
|
foreach ( $created as $id ) {
|
|
$this->guardians->deleteUser( $id );
|
|
}
|
|
$this->guardians->deleteUser( $guardianId );
|
|
|
|
return esc_html__( 'Could not create the account. Please contact the studio.', 'unsupervised-schedular' );
|
|
}
|
|
|
|
$created[] = $childId;
|
|
|
|
$this->recordAnswers( $questions, $child['answers'], $childId );
|
|
$this->recordAcceptances( $policyForms, $childId, $guardianId );
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* The account-question answers submitted with the form, keyed by question id.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function submittedAnswers(): array {
|
|
// The submit nonce is verified by the caller (render) before this runs.
|
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized in the loop below.
|
|
$raw = $_POST['us_answers'] ?? [];
|
|
if ( ! is_array( $raw ) ) {
|
|
return [];
|
|
}
|
|
|
|
$out = [];
|
|
foreach ( $raw as $questionId => $value ) {
|
|
$out[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Persist the submitted answers for each active account-signup question.
|
|
*
|
|
* @param list<Question> $questions
|
|
* @param array<int, string> $answers question_id => submitted value
|
|
*/
|
|
private function recordAnswers( array $questions, array $answers, int $userId ): void {
|
|
foreach ( $questions as $question ) {
|
|
$value = trim( (string) ( $answers[ (int) $question->id ] ?? '' ) );
|
|
if ( '' === $value ) {
|
|
continue;
|
|
}
|
|
|
|
$this->answers->insert(
|
|
new Answer(
|
|
questionId: (int) $question->id,
|
|
registrationType: Answer::REG_ACCOUNT,
|
|
registrationId: $userId,
|
|
studentId: $userId,
|
|
answerValue: $value,
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record account-time acceptances for each signup policy version.
|
|
*
|
|
* `$userId` is who the policy binds — the guardian for their own acceptance,
|
|
* or the child for one accepted on their behalf — and `$acceptedBy` is who
|
|
* actually ticked the box. Recording both is what makes the row legally
|
|
* meaningful: "guardian X agreed to version N for child Y, at this time, from
|
|
* this IP".
|
|
*
|
|
* @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
|
*/
|
|
private function recordAcceptances( array $policyForms, int $userId, int $acceptedBy ): void {
|
|
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP is stored verbatim for audit.
|
|
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
|
|
|
|
foreach ( $policyForms as $form ) {
|
|
$this->acceptances->insert(
|
|
new PolicyAcceptance(
|
|
policyVersionId: (int) $form['version']->id,
|
|
studentId: $userId,
|
|
registrationType: PolicyAcceptance::REG_ACCOUNT,
|
|
registrationId: $userId,
|
|
acceptedBy: $acceptedBy,
|
|
ipAddress: '' !== $ip ? $ip : null,
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Signup-scoped policies that have a current published version.
|
|
*
|
|
* @return list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}>
|
|
*/
|
|
private function signupPolicies(): array {
|
|
$out = [];
|
|
|
|
foreach ( $this->policies->findForScope( Policy::SCOPE_SIGNUP ) as $policy ) {
|
|
if ( null === $policy->currentVersionId ) {
|
|
continue;
|
|
}
|
|
|
|
$version = $this->versions->findById( $policy->currentVersionId );
|
|
if ( null === $version || ! $version->isPublished() ) {
|
|
continue;
|
|
}
|
|
|
|
$out[] = [
|
|
'policy' => $policy,
|
|
'version' => $version,
|
|
];
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
}
|