Add multi-use group invite links with expiry and auto-approval on email confirmation
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m46s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped

A studio admin can generate a shareable group invite link (e.g. for a
newsletter) from the Invites page, choosing a required expiry date. Anyone
with the link may register while it is valid, in any registration mode: the
form collects their own email, they must confirm it via the usual hashed
token, and confirming approves the account immediately — group signups never
enter the Pending Students queue.

- us_invites grows kind (personal/group) and expires_at; an explicit expiry
  wins over the personal 14-day window. Group links stay pending (multi-use)
  until revoked or expired.
- RegistrationPage: group signups create the account pending with the
  us_auto_approve marker and send the confirmation email; no auto-login.
- EmailConfirmationHandler: auto-approve accounts are approved on
  confirmation, emailed the approved notice, and redirected to a new
  us_confirmed=ready notice with a sign-in link.

Closes #77

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-22 10:44:16 -03:00
co-authored by Claude Fable 5
parent 681fc5ae07
commit 356d9f984d
15 changed files with 437 additions and 29 deletions
+21 -5
View File
@@ -9,6 +9,12 @@ anyone may sign up, confirm their email, and then be approved by a studio admin
before the account can be used. Both modes coexist — invites keep working when before the account can be used. Both modes coexist — invites keep working when
open registration is on. open registration is on.
A studio admin can also generate a **group invite link** — a multi-use, tokenised
link with an explicit expiry date (e.g. for a newsletter). Anyone with the link
may register while it is valid, regardless of the registration mode: they supply
their own email, must confirm it, and are then **approved automatically**
group-link signups never enter the Pending Students queue.
## Registration Modes ## Registration Modes
Stored in the `us_registration_mode` option (default `invite`), toggled from Stored in the `us_registration_mode` option (default `invite`), toggled from
**Studio Settings → Registration**: **Studio Settings → Registration**:
@@ -59,14 +65,16 @@ confirmation token's SHA-256 hash is stored; the token expires after 48h
| Column | Type | Notes | | Column | Type | Notes |
|--------------------|------------------|--------------------------------------------------------| |--------------------|------------------|--------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key | | `id` | BIGINT UNSIGNED | Primary key |
| `email` | VARCHAR(191) | Invited email address | | `email` | VARCHAR(191) | Invited email address; empty string for group links |
| `token` | VARCHAR(64) | SHA-256 hash of the token embedded in the registration link (raw token is never stored) | | `token` | VARCHAR(64) | SHA-256 hash of the token embedded in the registration link (raw token is never stored) |
| `role` | VARCHAR(32) | Role granted on acceptance (default `us_student`) | | `role` | VARCHAR(32) | Role granted on acceptance (default `us_student`) |
| `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` | | `kind` | VARCHAR(10) | `personal` (single-use, per email) or `group` (multi-use link) |
| `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` (group links stay `pending` until revoked/expired) |
| `invited_by` | BIGINT UNSIGNED | WordPress user ID of the studio admin who invited | | `invited_by` | BIGINT UNSIGNED | WordPress user ID of the studio admin who invited |
| `accepted_user_id` | BIGINT UNSIGNED | The created user's ID once accepted; NULL while pending | | `accepted_user_id` | BIGINT UNSIGNED | The created user's ID once accepted; NULL while pending / for group links |
| `created_at` | DATETIME | Insertion time | | `created_at` | DATETIME | Insertion time |
| `accepted_at` | DATETIME | When accepted; NULL while pending | | `accepted_at` | DATETIME | When accepted; NULL while pending / for group links |
| `expires_at` | DATETIME | Explicit expiry (end of the chosen day); set on every group link, NULL for personal invites (which expire 14 days after creation) |
## Policy Acceptance Scope ## Policy Acceptance Scope
Policies declare **when** they must be accepted via `us_policies.acceptance_scope`: Policies declare **when** they must be accepted via `us_policies.acceptance_scope`:
@@ -88,11 +96,19 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
4. The applicant opens the emailed `?us_confirm=<token>` link → email confirmed, studio admins notified. 4. The applicant opens the emailed `?us_confirm=<token>` link → email confirmed, studio admins notified.
5. Studio admin approves under **Students → Pending Students** → pending flags cleared, student emailed; they can now log in and book. Rejection deletes the account. 5. Studio admin approves under **Students → Pending Students** → pending flags cleared, student emailed; they can now log in and book. Rejection deletes the account.
## Flow (group invite link)
1. Studio admin opens **Invites** and generates a **group link**, choosing the expiry date (required; the link stops working at the end of that day). The link is shown **once**, like personal invite links.
2. Anyone opens the link while it is pending and unexpired — in **any** registration mode — and the form collects an **editable email**, display name, password, and the signup policies.
3. On submit the account is created pending with the auto-approve marker (`RegistrationStatus::markPending($userId, autoApprove: true)`, meta `us_auto_approve`) and a confirmation email is sent. The invite row is **not** marked accepted — the link remains usable by others.
4. Opening the `?us_confirm=<token>` link confirms the email and **approves the account immediately** (`EmailConfirmationHandler`): no admin heads-up, no Pending Students entry; the student gets the "approved" email and the page shows a "ready to use" notice (`?us_confirmed=ready`) with a sign-in link.
5. The link can be revoked at any time from the Invites page.
## Admin Interface ## Admin Interface
**Invites** in wp-admin (`manage_students`, studio admin only): **Invites** in wp-admin (`manage_students`, studio admin only):
- Select the **registration page** (the page hosting `[us_student_register]`), stored in the `us_registration_page_id` option; invitation links point there (falling back to the home page if unset) - Select the **registration page** (the page hosting `[us_student_register]`), stored in the `us_registration_page_id` option; invitation links point there (falling back to the home page if unset)
- Invite an email (creates a pending invite; the link is displayed once, at creation only) - Invite an email (creates a pending invite; the link is displayed once, at creation only)
- List pending invites (email + invited date); revoke an invite - Generate a **group invite link** with a required expiry date (link displayed once)
- List pending invites (email or "Group link", created + expiry dates); revoke an invite
**Pending Students** — submenu under Students (`manage_students`), only relevant in `self_approval` mode: **Pending Students** — submenu under Students (`manage_students`), only relevant in `self_approval` mode:
- "Awaiting approval" (email confirmed) — approve or reject - "Awaiting approval" (email confirmed) — approve or reject
+15
View File
@@ -55,6 +55,21 @@ class EmailConfirmationHandler {
RegistrationStatus::confirmEmail( $userId ); RegistrationStatus::confirmEmail( $userId );
$user = get_user_by( 'id', $userId ); $user = get_user_by( 'id', $userId );
// Group invite link signups skip the admin review queue: confirming the
// email approves the account on the spot, so the student can sign in
// immediately instead of waiting for a studio admin.
if ( RegistrationStatus::isAutoApprove( $userId ) ) {
RegistrationStatus::approve( $userId );
if ( $user instanceof \WP_User ) {
$this->mailer->sendApproved( $user );
}
wp_safe_redirect( add_query_arg( 'us_confirmed', 'ready', $base ) );
exit;
}
if ( $user instanceof \WP_User ) { if ( $user instanceof \WP_User ) {
$this->mailer->notifyAdminsPending( $user ); $this->mailer->notifyAdminsPending( $user );
} }
+32 -5
View File
@@ -11,6 +11,12 @@ class Invite {
public const STATUS_ACCEPTED = 'accepted'; public const STATUS_ACCEPTED = 'accepted';
public const STATUS_REVOKED = 'revoked'; public const STATUS_REVOKED = 'revoked';
/** Single-use invite addressed to one email. */
public const KIND_PERSONAL = 'personal';
/** Multi-use shareable link (e.g. for a newsletter) with an explicit expiry. */
public const KIND_GROUP = 'group';
/** /**
* All valid invite statuses. * All valid invite statuses.
* *
@@ -43,6 +49,8 @@ class Invite {
public readonly ?int $acceptedUserId = null, public readonly ?int $acceptedUserId = null,
public readonly ?string $acceptedAt = null, public readonly ?string $acceptedAt = null,
public readonly ?string $createdAt = null, public readonly ?string $createdAt = null,
public readonly string $kind = self::KIND_PERSONAL,
public readonly ?string $expiresAt = null,
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
@@ -56,27 +64,44 @@ class Invite {
acceptedUserId: Val::intOrNull( $row->accepted_user_id ), acceptedUserId: Val::intOrNull( $row->accepted_user_id ),
acceptedAt: Val::stringOrNull( $row->accepted_at ), acceptedAt: Val::stringOrNull( $row->accepted_at ),
createdAt: Val::stringOrNull( $row->created_at ?? null ), createdAt: Val::stringOrNull( $row->created_at ?? null ),
kind: '' !== Val::string( $row->kind ?? '' ) ? Val::string( $row->kind ) : self::KIND_PERSONAL,
expiresAt: Val::stringOrNull( $row->expires_at ?? null ),
id: Val::int( $row->id ), id: Val::int( $row->id ),
); );
} }
public function isGroup(): bool {
return self::KIND_GROUP === $this->kind;
}
public function isPending(): bool { public function isPending(): bool {
return self::STATUS_PENDING === $this->status; return self::STATUS_PENDING === $this->status;
} }
/** /**
* Whether the invite was created more than {@see EXPIRY_DAYS} ago, measured * Whether the invite has expired, measured against the supplied current
* against the supplied current `Y-m-d H:i:s` timestamp. An invite with no * `Y-m-d H:i:s` timestamp. An explicit `expires_at` (set on every group
* known creation time is treated as not expired. * link) wins; otherwise a personal invite expires {@see EXPIRY_DAYS} after
* creation. An invite with neither timestamp is treated as not expired.
*/ */
public function isExpired( string $now ): bool { public function isExpired( string $now ): bool {
$current = strtotime( $now );
if ( false === $current ) {
return false;
}
if ( null !== $this->expiresAt ) {
$expires = strtotime( $this->expiresAt );
return false !== $expires && $current > $expires;
}
if ( null === $this->createdAt ) { if ( null === $this->createdAt ) {
return false; return false;
} }
$created = strtotime( $this->createdAt ); $created = strtotime( $this->createdAt );
$current = strtotime( $now ); if ( false === $created ) {
if ( false === $created || false === $current ) {
return false; return false;
} }
@@ -101,10 +126,12 @@ class Invite {
'email' => $this->email, 'email' => $this->email,
'token' => $this->token, 'token' => $this->token,
'role' => $this->role, 'role' => $this->role,
'kind' => $this->kind,
'status' => $this->status, 'status' => $this->status,
'invited_by' => $this->invitedBy, 'invited_by' => $this->invitedBy,
'accepted_user_id' => $this->acceptedUserId, 'accepted_user_id' => $this->acceptedUserId,
'accepted_at' => $this->acceptedAt, 'accepted_at' => $this->acceptedAt,
'expires_at' => $this->expiresAt,
]; ];
} }
} }
+3 -1
View File
@@ -18,13 +18,15 @@ class InviteRepository {
'email' => $invite->email, 'email' => $invite->email,
'token' => $invite->token, 'token' => $invite->token,
'role' => $invite->role, 'role' => $invite->role,
'kind' => $invite->kind,
'status' => $invite->status, 'status' => $invite->status,
'invited_by' => $invite->invitedBy, 'invited_by' => $invite->invitedBy,
'accepted_user_id' => $invite->acceptedUserId, 'accepted_user_id' => $invite->acceptedUserId,
'created_at' => current_time( 'mysql' ), 'created_at' => current_time( 'mysql' ),
'accepted_at' => $invite->acceptedAt, 'accepted_at' => $invite->acceptedAt,
'expires_at' => $invite->expiresAt,
], ],
[ '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s' ] [ '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' ]
); );
return $this->db->insert_id; return $this->db->insert_id;
+37
View File
@@ -67,6 +67,26 @@ class RegistrationController {
} }
} }
if ( 'group_invite' === $action ) {
$expiresAt = $this->normalizeExpiry( sanitize_text_field( Val::string( wp_unslash( $_POST['expires_at'] ?? '' ) ) ) );
if ( null !== $expiresAt ) {
$rawToken = wp_generate_password( 32, false );
$this->invites->insert(
new Invite(
email: '',
token: Invite::hashToken( $rawToken ),
invitedBy: get_current_user_id(),
kind: Invite::KIND_GROUP,
expiresAt: $expiresAt,
)
);
return $this->registrationLink( $rawToken );
}
}
if ( 'revoke' === $action ) { if ( 'revoke' === $action ) {
$inviteId = absint( Val::int( $_POST['invite_id'] ?? 0 ) ); $inviteId = absint( Val::int( $_POST['invite_id'] ?? 0 ) );
if ( $inviteId > 0 ) { if ( $inviteId > 0 ) {
@@ -78,6 +98,23 @@ class RegistrationController {
return ''; return '';
} }
/**
* Validate a submitted group-link expiry date (strict `Y-m-d`, today or
* later) and expand it to the end of that day; null when invalid or past.
*/
private function normalizeExpiry( string $date ): ?string {
$day = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date );
if ( false === $day || $day->format( 'Y-m-d' ) !== $date ) {
return null;
}
if ( $date < Val::string( current_time( 'Y-m-d' ) ) ) {
return null;
}
return $date . ' 23:59:59';
}
/** /**
* Build the registration URL for a raw invite token. * Build the registration URL for a raw invite token.
*/ */
+19 -8
View File
@@ -19,6 +19,12 @@ class RegistrationPage {
/** Success signal: a self-signup was created and must confirm their email. */ /** Success signal: a self-signup was created and must confirm their email. */
private const RESULT_CONFIRM = 'confirm'; 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';
public function __construct( public function __construct(
private InviteRepository $invites, private InviteRepository $invites,
private PolicyRepository $policies, private PolicyRepository $policies,
@@ -56,7 +62,7 @@ class RegistrationPage {
if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) { if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) {
$result = $this->handleSubmit( $invite, $open ); $result = $this->handleSubmit( $invite, $open );
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM ], true ) ) { if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ) {
$successType = $result; $successType = $result;
} else { } else {
$error = $result; $error = $result;
@@ -128,8 +134,9 @@ class RegistrationPage {
return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' ); 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. // The email is fixed by a personal invite; group-link signups and
if ( $inviteValid ) { // self-signups supply their own.
if ( $inviteValid && ! $invite->isGroup() ) {
$email = $invite->email; $email = $invite->email;
} else { } else {
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) ); $email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
@@ -169,7 +176,7 @@ class RegistrationPage {
$this->recordAcceptances( $policyForms, (int) $userId ); $this->recordAcceptances( $policyForms, (int) $userId );
if ( $inviteValid ) { if ( $inviteValid && ! $invite->isGroup() ) {
$this->invites->markAccepted( (int) $invite->id, (int) $userId ); $this->invites->markAccepted( (int) $invite->id, (int) $userId );
wp_set_current_user( (int) $userId ); wp_set_current_user( (int) $userId );
@@ -178,15 +185,19 @@ class RegistrationPage {
return self::RESULT_INVITE; return self::RESULT_INVITE;
} }
// Self-approval: hold the account pending, email a confirmation link, and // Group-link signups and self-signups both stay pending until they
// do NOT log the user in — they must confirm and be approved first. // confirm their email; the group link is multi-use so it is never marked
$rawToken = RegistrationStatus::markPending( (int) $userId ); // 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 ); $user = get_user_by( 'id', (int) $userId );
if ( $user instanceof \WP_User ) { if ( $user instanceof \WP_User ) {
$this->mailer->sendConfirmation( $user, $this->confirmUrl( $rawToken ) ); $this->mailer->sendConfirmation( $user, $this->confirmUrl( $rawToken ) );
} }
return self::RESULT_CONFIRM; return $autoApprove ? self::RESULT_CONFIRM_GROUP : self::RESULT_CONFIRM;
} }
/** /**
+22 -1
View File
@@ -27,6 +27,12 @@ class RegistrationStatus {
public const META_CONFIRM_TOKEN = 'us_email_confirm_token'; public const META_CONFIRM_TOKEN = 'us_email_confirm_token';
public const META_CONFIRM_EXPIRES = 'us_email_confirm_expires'; public const META_CONFIRM_EXPIRES = 'us_email_confirm_expires';
/**
* Set on accounts created via a group invite link: confirming the email
* approves the account immediately instead of queueing it for admin review.
*/
public const META_AUTO_APPROVE = 'us_auto_approve';
/** /**
* Hours a self-signup email-confirmation link stays valid after the account * 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. * is created. Limits the window in which a leaked link can be redeemed.
@@ -45,8 +51,10 @@ class RegistrationStatus {
/** /**
* Put a freshly created user into the pending state and issue an email * 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. * confirmation token. Returns the raw token to embed in the emailed link.
* With `$autoApprove` (group invite links) confirming the email approves
* the account immediately — no admin review step.
*/ */
public static function markPending( int $userId ): string { public static function markPending( int $userId, bool $autoApprove = false ): string {
$rawToken = wp_generate_password( 32, false ); $rawToken = wp_generate_password( 32, false );
update_user_meta( $userId, self::META_AWAITING_APPROVAL, '1' ); update_user_meta( $userId, self::META_AWAITING_APPROVAL, '1' );
@@ -57,6 +65,10 @@ class RegistrationStatus {
gmdate( 'Y-m-d H:i:s', time() + self::EMAIL_CONFIRM_EXPIRY_HOURS * 3600 ) gmdate( 'Y-m-d H:i:s', time() + self::EMAIL_CONFIRM_EXPIRY_HOURS * 3600 )
); );
if ( $autoApprove ) {
update_user_meta( $userId, self::META_AUTO_APPROVE, '1' );
}
return $rawToken; return $rawToken;
} }
@@ -78,12 +90,21 @@ class RegistrationStatus {
delete_user_meta( $userId, self::META_AWAITING_APPROVAL ); delete_user_meta( $userId, self::META_AWAITING_APPROVAL );
delete_user_meta( $userId, self::META_CONFIRM_TOKEN ); delete_user_meta( $userId, self::META_CONFIRM_TOKEN );
delete_user_meta( $userId, self::META_CONFIRM_EXPIRES ); delete_user_meta( $userId, self::META_CONFIRM_EXPIRES );
delete_user_meta( $userId, self::META_AUTO_APPROVE );
} }
public static function isAwaitingApproval( int $userId ): bool { public static function isAwaitingApproval( int $userId ): bool {
return '1' === Val::string( get_user_meta( $userId, self::META_AWAITING_APPROVAL, true ) ); return '1' === Val::string( get_user_meta( $userId, self::META_AWAITING_APPROVAL, true ) );
} }
/**
* Whether confirming this account's email should approve it immediately
* (group invite link signups).
*/
public static function isAutoApprove( int $userId ): bool {
return '1' === Val::string( get_user_meta( $userId, self::META_AUTO_APPROVE, true ) );
}
public static function emailConfirmed( int $userId ): bool { public static function emailConfirmed( int $userId ): bool {
return '1' === Val::string( get_user_meta( $userId, self::META_EMAIL_CONFIRMED, true ) ); return '1' === Val::string( get_user_meta( $userId, self::META_EMAIL_CONFIRMED, true ) );
} }
+2
View File
@@ -186,11 +186,13 @@ class Schema {
email VARCHAR(191) NOT NULL, email VARCHAR(191) NOT NULL,
token VARCHAR(64) NOT NULL, token VARCHAR(64) NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'us_student', role VARCHAR(32) NOT NULL DEFAULT 'us_student',
kind VARCHAR(10) NOT NULL DEFAULT 'personal',
status VARCHAR(20) NOT NULL DEFAULT 'pending', status VARCHAR(20) NOT NULL DEFAULT 'pending',
invited_by BIGINT UNSIGNED DEFAULT NULL, invited_by BIGINT UNSIGNED DEFAULT NULL,
accepted_user_id BIGINT UNSIGNED DEFAULT NULL, accepted_user_id BIGINT UNSIGNED DEFAULT NULL,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
accepted_at DATETIME DEFAULT NULL, accepted_at DATETIME DEFAULT NULL,
expires_at DATETIME DEFAULT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY token (token), UNIQUE KEY token (token),
KEY email (email), KEY email (email),
+24 -3
View File
@@ -66,6 +66,23 @@ if (! defined('ABSPATH')) {
<?php submit_button(esc_html__('Generate Invitation Link', 'unsupervised-schedular')); ?> <?php submit_button(esc_html__('Generate Invitation Link', 'unsupervised-schedular')); ?>
</form> </form>
<h2><?php esc_html_e('Group Invite Link', 'unsupervised-schedular'); ?></h2>
<p class="description"><?php esc_html_e('Generate a shareable link (e.g. for a newsletter). Anyone with the link can register until it expires: they enter their own email and must confirm it, but no admin approval is needed afterwards.', 'unsupervised-schedular'); ?></p>
<form method="post">
<?php wp_nonce_field('usc_invite_action'); ?>
<input type="hidden" name="usc_action" value="group_invite">
<table class="form-table">
<tr>
<th><label for="expires_at"><?php esc_html_e('Expires on', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="date" name="expires_at" id="expires_at" required min="<?php echo esc_attr((string) current_time('Y-m-d')); ?>">
<p class="description"><?php esc_html_e('The link stops working at the end of this day.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
</table>
<?php submit_button(esc_html__('Generate Group Link', 'unsupervised-schedular')); ?>
</form>
<h2><?php esc_html_e('Pending Invites', 'unsupervised-schedular'); ?></h2> <h2><?php esc_html_e('Pending Invites', 'unsupervised-schedular'); ?></h2>
<?php if (empty($pendingInvites)) : ?> <?php if (empty($pendingInvites)) : ?>
@@ -74,8 +91,9 @@ if (! defined('ABSPATH')) {
<table class="wp-list-table widefat fixed striped"> <table class="wp-list-table widefat fixed striped">
<thead> <thead>
<tr> <tr>
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Invite', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Invited', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Created', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Expires', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
</tr> </tr>
</thead> </thead>
@@ -84,7 +102,7 @@ if (! defined('ABSPATH')) {
<?php foreach ($pendingInvites as $invite) : ?> <?php foreach ($pendingInvites as $invite) : ?>
<tr> <tr>
<td> <td>
<?php echo esc_html($invite->email); ?> <?php echo $invite->isGroup() ? esc_html__('Group link', 'unsupervised-schedular') : esc_html($invite->email); ?>
<?php if ($invite->isExpired($now)) : ?> <?php if ($invite->isExpired($now)) : ?>
<span class="us-invite-expired" style="color:#b32d2e;">— <?php esc_html_e('expired', 'unsupervised-schedular'); ?></span> <span class="us-invite-expired" style="color:#b32d2e;">— <?php esc_html_e('expired', 'unsupervised-schedular'); ?></span>
<?php endif; ?> <?php endif; ?>
@@ -92,6 +110,9 @@ if (! defined('ABSPATH')) {
<td> <td>
<?php echo esc_html((string) $invite->createdAt); ?> <?php echo esc_html((string) $invite->createdAt); ?>
</td> </td>
<td>
<?php echo esc_html($invite->expiresAt !== null ? (string) mysql2date('M j, Y', $invite->expiresAt) : '—'); ?>
</td>
<td> <td>
<form method="post" style="display:inline;"> <form method="post" style="display:inline;">
<?php wp_nonce_field('usc_invite_action'); ?> <?php wp_nonce_field('usc_invite_action'); ?>
+8 -3
View File
@@ -11,8 +11,8 @@ if (! defined('ABSPATH')) {
* @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 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). * @var string $successType '' | 'invite' (created + logged in) | 'confirm' (check email) | 'confirm_group' (check email; auto-approved on confirm).
* @var string $confirmResult '' | '1' (email confirmed) | 'expired'. * @var string $confirmResult '' | '1' (email confirmed, awaiting approval) | 'ready' (confirmed + auto-approved) | 'expired'.
* @var string $loginUrl Where the post-confirmation sign-in link points. * @var string $loginUrl Where the post-confirmation sign-in link points.
* @var string $error * @var string $error
* @var list<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms * @var list<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
@@ -23,6 +23,11 @@ if (! defined('ABSPATH')) {
<p class="us-success"><?php esc_html_e('Your account has been created and you are now logged in.', 'unsupervised-schedular'); ?></p> <p class="us-success"><?php esc_html_e('Your account has been created and you are now logged in.', 'unsupervised-schedular'); ?></p>
<?php elseif ($successType === 'confirm') : ?> <?php elseif ($successType === 'confirm') : ?>
<p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, a studio admin will review and approve your account.', 'unsupervised-schedular'); ?></p> <p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, a studio admin will review and approve your account.', 'unsupervised-schedular'); ?></p>
<?php elseif ($successType === 'confirm_group') : ?>
<p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, your account is ready to use.', 'unsupervised-schedular'); ?></p>
<?php elseif ($confirmResult === 'ready') : ?>
<p class="us-success"><?php esc_html_e('Thanks — your email is confirmed and your account is ready to use.', 'unsupervised-schedular'); ?></p>
<p><a href="<?php echo esc_url($loginUrl); ?>"><?php esc_html_e('Sign in to your account', 'unsupervised-schedular'); ?></a></p>
<?php elseif ($confirmResult === '1') : ?> <?php elseif ($confirmResult === '1') : ?>
<p class="us-success"><?php esc_html_e('Thanks — your email is confirmed. Your account is now awaiting studio approval; we will email you when it is ready.', 'unsupervised-schedular'); ?></p> <p class="us-success"><?php esc_html_e('Thanks — your email is confirmed. Your account is now awaiting studio approval; we will email you when it is ready.', 'unsupervised-schedular'); ?></p>
<p><a href="<?php echo esc_url($loginUrl); ?>"><?php esc_html_e('Sign in to your account', 'unsupervised-schedular'); ?></a></p> <p><a href="<?php echo esc_url($loginUrl); ?>"><?php esc_html_e('Sign in to your account', 'unsupervised-schedular'); ?></a></p>
@@ -44,7 +49,7 @@ if (! defined('ABSPATH')) {
<p> <p>
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label> <label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
<?php if ($inviteValid && $invite !== null) : ?> <?php if ($inviteValid && $invite !== null && ! $invite->isGroup()) : ?>
<input type="email" id="us-reg-email" value="<?php echo esc_attr($invite->email); ?>" readonly> <input type="email" id="us-reg-email" value="<?php echo esc_attr($invite->email); ?>" readonly>
<?php else : ?> <?php else : ?>
<input type="email" name="email" id="us-reg-email" autocomplete="email" required> <input type="email" name="email" id="us-reg-email" autocomplete="email" required>
@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth; namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\EmailConfirmationHandler; use Unsupervised\Schedular\Auth\EmailConfirmationHandler;
use Unsupervised\Schedular\Auth\RegistrationController; use Unsupervised\Schedular\Auth\RegistrationController;
use Unsupervised\Schedular\Auth\RegistrationMailer; use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RegistrationStatus;
use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Tests\Unit\TestCase; use Unsupervised\Schedular\Tests\Unit\TestCase;
@@ -22,9 +24,84 @@ class EmailConfirmationHandlerTest extends TestCase
protected function tearDown(): void protected function tearDown(): void
{ {
unset($_REQUEST['action']); unset($_REQUEST['action']);
$_GET = [];
parent::tearDown(); parent::tearDown();
} }
/**
* Stub everything maybeConfirm() needs for a valid token belonging to user
* 9, with wp_safe_redirect throwing so the redirect URL can be asserted.
*
* @param bool $autoApprove Whether user 9 carries the auto-approve marker.
*/
private function stubConfirmContext(bool $autoApprove): void
{
$this->stubMode(StudioSettings::MODE_INVITE);
Functions\when('is_admin')->justReturn(false);
Functions\when('sanitize_text_field')->returnArg();
Functions\when('get_permalink')->justReturn('http://wp/register/');
Functions\when('add_query_arg')->alias(static fn (string $k, string $v, string $u): string => $u . '?' . $k . '=' . $v);
Functions\when('get_users')->justReturn([9]);
Functions\when('get_user_meta')->alias(static function (int $id, string $key) use ($autoApprove) {
if ($key === RegistrationStatus::META_CONFIRM_EXPIRES) {
return '2030-01-01 00:00:00';
}
if ($key === RegistrationStatus::META_AUTO_APPROVE) {
return $autoApprove ? '1' : '';
}
return '';
});
Functions\when('update_user_meta')->justReturn(true);
Functions\when('delete_user_meta')->justReturn(true);
Functions\when('wp_safe_redirect')->alias(static function (string $url): void {
throw new \RuntimeException('redirect:' . $url);
});
$_GET['us_confirm'] = 'rawtoken';
}
public function testConfirmAutoApprovesGroupLinkSignupWithoutAdminReview(): void
{
$this->stubConfirmContext(true);
$user = Mockery::mock(\WP_User::class);
Functions\when('get_user_by')->justReturn($user);
$mailer = Mockery::mock(RegistrationMailer::class);
$mailer->shouldReceive('sendApproved')->once()->with($user)->andReturn(true);
$mailer->shouldNotReceive('notifyAdminsPending');
$handler = new EmailConfirmationHandler(new StudioSettings(), $mailer);
try {
$handler->maybeConfirm();
self::fail('Expected a redirect');
} catch (\RuntimeException $e) {
self::assertStringContainsString('us_confirmed=ready', $e->getMessage());
}
}
public function testConfirmWithoutAutoApproveNotifiesAdminsAndStaysPending(): void
{
$this->stubConfirmContext(false);
$user = Mockery::mock(\WP_User::class);
Functions\when('get_user_by')->justReturn($user);
$mailer = Mockery::mock(RegistrationMailer::class);
$mailer->shouldReceive('notifyAdminsPending')->once()->with($user)->andReturn(true);
$mailer->shouldNotReceive('sendApproved');
$handler = new EmailConfirmationHandler(new StudioSettings(), $mailer);
try {
$handler->maybeConfirm();
self::fail('Expected a redirect');
} catch (\RuntimeException $e) {
self::assertStringContainsString('us_confirmed=1', $e->getMessage());
}
}
private function handler(): EmailConfirmationHandler private function handler(): EmailConfirmationHandler
{ {
return new EmailConfirmationHandler(new StudioSettings(), new RegistrationMailer()); return new EmailConfirmationHandler(new StudioSettings(), new RegistrationMailer());
+26 -2
View File
@@ -34,16 +34,40 @@ class InviteRepositoryTest extends TestCase
Mockery::on(static function (array $d): bool { Mockery::on(static function (array $d): bool {
return $d['email'] === '[email protected]' return $d['email'] === '[email protected]'
&& $d['token'] === 'tok123' && $d['token'] === 'tok123'
&& $d['kind'] === Invite::KIND_PERSONAL
&& $d['status'] === Invite::STATUS_PENDING && $d['status'] === Invite::STATUS_PENDING
&& $d['invited_by'] === 2; && $d['invited_by'] === 2
&& $d['expires_at'] === null;
}), }),
['%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s'] ['%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s']
); );
$this->db->insert_id = 5; $this->db->insert_id = 5;
self::assertSame(5, $this->repo->insert(new Invite('[email protected]', 'tok123', invitedBy: 2))); self::assertSame(5, $this->repo->insert(new Invite('[email protected]', 'tok123', invitedBy: 2)));
} }
public function testInsertPersistsGroupKindAndExpiry(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 09:00:00');
$this->db->shouldReceive('insert')
->once()
->with(
'wp_us_invites',
Mockery::on(static function (array $d): bool {
return $d['email'] === ''
&& $d['kind'] === Invite::KIND_GROUP
&& $d['expires_at'] === '2026-08-31 23:59:59';
}),
Mockery::type('array')
);
$this->db->insert_id = 6;
$invite = new Invite('', 'tok456', invitedBy: 2, kind: Invite::KIND_GROUP, expiresAt: '2026-08-31 23:59:59');
self::assertSame(6, $this->repo->insert($invite));
}
public function testFindByTokenReturnsInvite(): void public function testFindByTokenReturnsInvite(): void
{ {
$this->db->shouldReceive('prepare') $this->db->shouldReceive('prepare')
+47 -1
View File
@@ -95,6 +95,52 @@ class InviteTest extends TestCase
self::assertFalse($invite->isAcceptable('2026-06-02 09:00:00')); self::assertFalse($invite->isAcceptable('2026-06-02 09:00:00'));
} }
public function testGroupInviteHonoursExplicitExpiry(): void
{
$invite = new Invite(
'',
'tok',
createdAt: '2026-06-01 09:00:00',
kind: Invite::KIND_GROUP,
expiresAt: '2026-08-31 23:59:59'
);
self::assertTrue($invite->isGroup());
// 19 days after creation — past the personal 14-day window, but the
// explicit expiry governs.
self::assertFalse($invite->isExpired('2026-06-20 09:00:00'));
self::assertTrue($invite->isAcceptable('2026-06-20 09:00:00'));
self::assertTrue($invite->isExpired('2026-09-01 00:00:00'));
self::assertFalse($invite->isAcceptable('2026-09-01 00:00:00'));
}
public function testExplicitExpiryWinsOverCreationWindowForPersonalInvites(): void
{
$invite = new Invite('[email protected]', 'tok', createdAt: '2026-06-01 09:00:00', expiresAt: '2026-06-02 23:59:59');
// One day old (inside the 14-day window) but past its explicit expiry.
self::assertTrue($invite->isExpired('2026-06-03 09:00:00'));
}
public function testFromRowDefaultsKindWhenColumnMissing(): void
{
$invite = Invite::fromRow((object) [
'id' => '5',
'email' => '[email protected]',
'token' => 'tok123',
'role' => RoleManager::STUDENT,
'status' => Invite::STATUS_PENDING,
'invited_by' => null,
'accepted_user_id' => null,
'accepted_at' => null,
]);
self::assertSame(Invite::KIND_PERSONAL, $invite->kind);
self::assertFalse($invite->isGroup());
self::assertNull($invite->expiresAt);
}
public function testHashTokenIsDeterministicSha256(): void public function testHashTokenIsDeterministicSha256(): void
{ {
$hash = Invite::hashToken('raw-token'); $hash = Invite::hashToken('raw-token');
@@ -109,7 +155,7 @@ class InviteTest extends TestCase
{ {
$arr = (new Invite('[email protected]', 'tok', id: 1))->toArray(); $arr = (new Invite('[email protected]', 'tok', id: 1))->toArray();
foreach (['id', 'email', 'token', 'role', 'status', 'invited_by', 'accepted_user_id', 'accepted_at'] as $key) { foreach (['id', 'email', 'token', 'role', 'kind', 'status', 'invited_by', 'accepted_user_id', 'accepted_at', 'expires_at'] as $key) {
self::assertArrayHasKey($key, $arr); self::assertArrayHasKey($key, $arr);
} }
} }
+73
View File
@@ -123,6 +123,79 @@ class RegistrationPageTest extends TestCase
self::assertSame('confirm', $this->submit(null, true)); self::assertSame('confirm', $this->submit(null, true));
} }
public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->justReturn(false);
Functions\when('wp_generate_password')->justReturn('rawtok');
// confirmUrl internals
Functions\when('get_option')->justReturn(0);
Functions\when('home_url')->justReturn('http://home.test/');
Functions\when('add_query_arg')->alias(static fn (string $k, string $v, string $u): string => $u . '?' . $k . '=' . $v);
// The auto-approve marker must be set alongside the pending metas.
$metas = [];
Functions\when('update_user_meta')->alias(static function (int $id, string $key, $value) use (&$metas): bool {
$metas[$key] = $value;
return true;
});
$user = Mockery::mock(\WP_User::class);
Functions\when('get_user_by')->justReturn($user);
$this->ctx['mailer']->shouldReceive('sendConfirmation')->once()->with($user, Mockery::type('string'));
// The link is multi-use: never marked accepted, and no auto-login.
$this->ctx['invites']->shouldReceive('markAccepted')->never();
Functions\expect('wp_set_auth_cookie')->never();
$invite = new Invite(
email: '',
token: 'hash',
createdAt: '2024-01-01 00:00:00',
kind: Invite::KIND_GROUP,
expiresAt: '2024-02-01 23:59:59',
id: 9
);
// Registration mode is invite-only (open = false): the group link still works.
self::assertSame('confirm_group', $this->submit($invite, false));
self::assertSame('1', $metas['us_auto_approve'] ?? null);
}
public function testGroupInviteRendersEditableEmailField(): void
{
$_REQUEST = ['us_invite' => 'raw-token'];
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('');
// Invite-only mode: only the group link grants access to the form.
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(false);
$invite = new Invite(
email: '',
token: 'hash',
createdAt: '2024-01-01 00:00:00',
kind: Invite::KIND_GROUP,
expiresAt: '2024-02-01 23:59:59',
id: 9
);
$this->ctx['invites']->shouldReceive('findByToken')
->once()
->with(Invite::hashToken('raw-token'))
->andReturn($invite);
$html = $this->ctx['page']->render([]);
self::assertStringContainsString('<form', $html);
self::assertStringContainsString('name="email"', $html);
self::assertStringNotContainsString('readonly', $html);
}
public function testClosedModeWithoutInviteReturnsError(): void public function testClosedModeWithoutInviteReturnsError(): void
{ {
$result = $this->submit(null, false); $result = $this->submit(null, false);
@@ -42,10 +42,41 @@ class RegistrationStatusTest extends TestCase
Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_AWAITING_APPROVAL); Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_AWAITING_APPROVAL);
Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_CONFIRM_TOKEN); Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_CONFIRM_TOKEN);
Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_CONFIRM_EXPIRES); Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_CONFIRM_EXPIRES);
Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_AUTO_APPROVE);
RegistrationStatus::approve(7); RegistrationStatus::approve(7);
} }
public function testMarkPendingWithAutoApproveSetsMarkerMeta(): void
{
Functions\when('wp_generate_password')->justReturn('rawtoken');
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_AWAITING_APPROVAL, '1');
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_CONFIRM_TOKEN, hash('sha256', 'rawtoken'));
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_CONFIRM_EXPIRES, \Mockery::type('string'));
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_AUTO_APPROVE, '1');
self::assertSame('rawtoken', RegistrationStatus::markPending(7, true));
}
public function testIsAutoApproveReadsMeta(): void
{
Functions\when('get_user_meta')->alias(static function (int $id, string $key) {
return $key === RegistrationStatus::META_AUTO_APPROVE ? '1' : '';
});
self::assertTrue(RegistrationStatus::isAutoApprove(7));
self::assertFalse(RegistrationStatus::isAwaitingApproval(7));
}
public function testAwaitingApprovalAndEmailConfirmedReadMeta(): void public function testAwaitingApprovalAndEmailConfirmedReadMeta(): void
{ {
Functions\when('get_user_meta')->alias(static function (int $id, string $key) { Functions\when('get_user_meta')->alias(static function (int $id, string $key) {