Add open student registration with email confirmation and approval
CI / Tests (PHP 8.1) (pull_request) Successful in 1m18s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m18s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 3m20s
CI / Coding Standards (pull_request) Successful in 3m25s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m33s
CI / Build Plugin Zip (pull_request) Skipped

Students could previously join by invite only. Add an optional
self-approval mode, toggled from Studio Settings → Registration: anyone
may sign up on the existing [us_student_register] page, confirm their
email via a tokenised link, and then be approved by a studio admin
before the account is usable.

- Enabling the toggle mirrors WordPress's own membership settings
  (users_can_register + default_role = us_student) and snapshots their
  previous values so disabling restores them.
- WordPress's native registration form is blocked while open
  registration is on (login_init redirect + registration_errors
  fail-safe + register_url) so it cannot bypass signup policy acceptance.
- Pending accounts: unconfirmed email cannot log in; confirmed but
  unapproved can log in but the booking capability is withheld and the
  booking page shows an "awaiting approval" screen.
- Approve/reject from Students → Pending Students; reject hard-deletes
  the account so the email is freed to re-apply.
- Invite registration is unchanged; both modes coexist.

Account lifecycle lives in user meta (RegistrationStatus); no new tables.

Closes #63

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
2026-07-18 10:50:21 -03:00
co-authored by Claude Opus 4.8
parent e7d8257973
commit 7370755951
23 changed files with 1713 additions and 88 deletions
+128
View File
@@ -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( '/' );
}
}
+102
View File
@@ -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
)
);
}
}
+61
View File
@@ -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;
}
}
+113
View File
@@ -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' );
}
}
+73 -19
View File
@@ -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 );
}
/**
+135
View File
@@ -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;
}
}