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
+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;
}
}