Add multi-use group invite links with expiry and auto-approval on email confirmation #83
@@ -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
|
||||
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
|
||||
Stored in the `us_registration_mode` option (default `invite`), toggled from
|
||||
**Studio Settings → Registration**:
|
||||
@@ -59,14 +65,16 @@ confirmation token's SHA-256 hash is stored; the token expires after 48h
|
||||
| Column | Type | Notes |
|
||||
|--------------------|------------------|--------------------------------------------------------|
|
||||
| `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) |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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
|
||||
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.
|
||||
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
|
||||
**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)
|
||||
- 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:
|
||||
- "Awaiting approval" (email confirmed) — approve or reject
|
||||
|
||||
@@ -55,6 +55,21 @@ class EmailConfirmationHandler {
|
||||
RegistrationStatus::confirmEmail( $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 ) {
|
||||
$this->mailer->notifyAdminsPending( $user );
|
||||
}
|
||||
|
||||
+32
-5
@@ -11,6 +11,12 @@ class Invite {
|
||||
public const STATUS_ACCEPTED = 'accepted';
|
||||
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.
|
||||
*
|
||||
@@ -43,6 +49,8 @@ class Invite {
|
||||
public readonly ?int $acceptedUserId = null,
|
||||
public readonly ?string $acceptedAt = null,
|
||||
public readonly ?string $createdAt = null,
|
||||
public readonly string $kind = self::KIND_PERSONAL,
|
||||
public readonly ?string $expiresAt = null,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
@@ -56,27 +64,44 @@ class Invite {
|
||||
acceptedUserId: Val::intOrNull( $row->accepted_user_id ),
|
||||
acceptedAt: Val::stringOrNull( $row->accepted_at ),
|
||||
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 ),
|
||||
);
|
||||
}
|
||||
|
||||
public function isGroup(): bool {
|
||||
return self::KIND_GROUP === $this->kind;
|
||||
}
|
||||
|
||||
public function isPending(): bool {
|
||||
return self::STATUS_PENDING === $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the invite was created more than {@see EXPIRY_DAYS} ago, measured
|
||||
* against the supplied current `Y-m-d H:i:s` timestamp. An invite with no
|
||||
* known creation time is treated as not expired.
|
||||
* Whether the invite has expired, measured against the supplied current
|
||||
* `Y-m-d H:i:s` timestamp. An explicit `expires_at` (set on every group
|
||||
* 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 {
|
||||
$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 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$created = strtotime( $this->createdAt );
|
||||
$current = strtotime( $now );
|
||||
if ( false === $created || false === $current ) {
|
||||
if ( false === $created ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -101,10 +126,12 @@ class Invite {
|
||||
'email' => $this->email,
|
||||
'token' => $this->token,
|
||||
'role' => $this->role,
|
||||
'kind' => $this->kind,
|
||||
'status' => $this->status,
|
||||
'invited_by' => $this->invitedBy,
|
||||
'accepted_user_id' => $this->acceptedUserId,
|
||||
'accepted_at' => $this->acceptedAt,
|
||||
'expires_at' => $this->expiresAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,15 @@ class InviteRepository {
|
||||
'email' => $invite->email,
|
||||
'token' => $invite->token,
|
||||
'role' => $invite->role,
|
||||
'kind' => $invite->kind,
|
||||
'status' => $invite->status,
|
||||
'invited_by' => $invite->invitedBy,
|
||||
'accepted_user_id' => $invite->acceptedUserId,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
'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;
|
||||
|
||||
@@ -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 ) {
|
||||
$inviteId = absint( Val::int( $_POST['invite_id'] ?? 0 ) );
|
||||
if ( $inviteId > 0 ) {
|
||||
@@ -78,6 +98,23 @@ class RegistrationController {
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -19,6 +19,12 @@ class RegistrationPage {
|
||||
/** 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';
|
||||
|
||||
public function __construct(
|
||||
private InviteRepository $invites,
|
||||
private PolicyRepository $policies,
|
||||
@@ -56,7 +62,7 @@ class RegistrationPage {
|
||||
|
||||
if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) {
|
||||
$result = $this->handleSubmit( $invite, $open );
|
||||
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM ], true ) ) {
|
||||
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ) {
|
||||
$successType = $result;
|
||||
} else {
|
||||
$error = $result;
|
||||
@@ -128,8 +134,9 @@ 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 ) {
|
||||
// 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'] ?? '' ) ) );
|
||||
@@ -169,7 +176,7 @@ class RegistrationPage {
|
||||
|
||||
$this->recordAcceptances( $policyForms, (int) $userId );
|
||||
|
||||
if ( $inviteValid ) {
|
||||
if ( $inviteValid && ! $invite->isGroup() ) {
|
||||
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
|
||||
|
||||
wp_set_current_user( (int) $userId );
|
||||
@@ -178,15 +185,19 @@ class RegistrationPage {
|
||||
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 );
|
||||
// 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 self::RESULT_CONFIRM;
|
||||
return $autoApprove ? self::RESULT_CONFIRM_GROUP : self::RESULT_CONFIRM;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,12 @@ class RegistrationStatus {
|
||||
public const META_CONFIRM_TOKEN = 'us_email_confirm_token';
|
||||
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
|
||||
* 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
|
||||
* 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 );
|
||||
|
||||
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 )
|
||||
);
|
||||
|
||||
if ( $autoApprove ) {
|
||||
update_user_meta( $userId, self::META_AUTO_APPROVE, '1' );
|
||||
}
|
||||
|
||||
return $rawToken;
|
||||
}
|
||||
|
||||
@@ -78,12 +90,21 @@ class RegistrationStatus {
|
||||
delete_user_meta( $userId, self::META_AWAITING_APPROVAL );
|
||||
delete_user_meta( $userId, self::META_CONFIRM_TOKEN );
|
||||
delete_user_meta( $userId, self::META_CONFIRM_EXPIRES );
|
||||
delete_user_meta( $userId, self::META_AUTO_APPROVE );
|
||||
}
|
||||
|
||||
public static function isAwaitingApproval( int $userId ): bool {
|
||||
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 {
|
||||
return '1' === Val::string( get_user_meta( $userId, self::META_EMAIL_CONFIRMED, true ) );
|
||||
}
|
||||
|
||||
@@ -186,11 +186,13 @@ class Schema {
|
||||
email VARCHAR(191) NOT NULL,
|
||||
token VARCHAR(64) NOT NULL,
|
||||
role VARCHAR(32) NOT NULL DEFAULT 'us_student',
|
||||
kind VARCHAR(10) NOT NULL DEFAULT 'personal',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
invited_by BIGINT UNSIGNED DEFAULT NULL,
|
||||
accepted_user_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
accepted_at DATETIME DEFAULT NULL,
|
||||
expires_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY token (token),
|
||||
KEY email (email),
|
||||
|
||||
@@ -66,6 +66,23 @@ if (! defined('ABSPATH')) {
|
||||
<?php submit_button(esc_html__('Generate Invitation Link', 'unsupervised-schedular')); ?>
|
||||
</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>
|
||||
|
||||
<?php if (empty($pendingInvites)) : ?>
|
||||
@@ -74,8 +91,9 @@ if (! defined('ABSPATH')) {
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Invited', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Invite', '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>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -84,7 +102,7 @@ if (! defined('ABSPATH')) {
|
||||
<?php foreach ($pendingInvites as $invite) : ?>
|
||||
<tr>
|
||||
<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)) : ?>
|
||||
<span class="us-invite-expired" style="color:#b32d2e;">— <?php esc_html_e('expired', 'unsupervised-schedular'); ?></span>
|
||||
<?php endif; ?>
|
||||
@@ -92,6 +110,9 @@ if (! defined('ABSPATH')) {
|
||||
<td>
|
||||
<?php echo esc_html((string) $invite->createdAt); ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo esc_html($invite->expiresAt !== null ? (string) mysql2date('M j, Y', $invite->expiresAt) : '—'); ?>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" style="display:inline;">
|
||||
<?php wp_nonce_field('usc_invite_action'); ?>
|
||||
|
||||
@@ -11,8 +11,8 @@ if (! defined('ABSPATH')) {
|
||||
* @var string $token Raw invite token from the request (only its hash is stored).
|
||||
* @var bool $canRegister
|
||||
* @var bool $open Whether open (self-approval) registration is enabled.
|
||||
* @var string $successType '' | 'invite' (created + logged in) | 'confirm' (check email).
|
||||
* @var string $confirmResult '' | '1' (email confirmed) | 'expired'.
|
||||
* @var string $successType '' | 'invite' (created + logged in) | 'confirm' (check email) | 'confirm_group' (check email; auto-approved on confirm).
|
||||
* @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 $error
|
||||
* @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>
|
||||
<?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>
|
||||
<?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') : ?>
|
||||
<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>
|
||||
@@ -44,7 +49,7 @@ if (! defined('ABSPATH')) {
|
||||
|
||||
<p>
|
||||
<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>
|
||||
<?php else : ?>
|
||||
<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;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\EmailConfirmationHandler;
|
||||
use Unsupervised\Schedular\Auth\RegistrationController;
|
||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
@@ -22,9 +24,84 @@ class EmailConfirmationHandlerTest extends TestCase
|
||||
protected function tearDown(): void
|
||||
{
|
||||
unset($_REQUEST['action']);
|
||||
$_GET = [];
|
||||
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
|
||||
{
|
||||
return new EmailConfirmationHandler(new StudioSettings(), new RegistrationMailer());
|
||||
|
||||
@@ -34,16 +34,40 @@ class InviteRepositoryTest extends TestCase
|
||||
Mockery::on(static function (array $d): bool {
|
||||
return $d['email'] === '[email protected]'
|
||||
&& $d['token'] === 'tok123'
|
||||
&& $d['kind'] === Invite::KIND_PERSONAL
|
||||
&& $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;
|
||||
|
||||
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
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
|
||||
@@ -95,6 +95,52 @@ class InviteTest extends TestCase
|
||||
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
|
||||
{
|
||||
$hash = Invite::hashToken('raw-token');
|
||||
@@ -109,7 +155,7 @@ class InviteTest extends TestCase
|
||||
{
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,79 @@ class RegistrationPageTest extends TestCase
|
||||
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
|
||||
{
|
||||
$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_CONFIRM_TOKEN);
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
Functions\when('get_user_meta')->alias(static function (int $id, string $key) {
|
||||
|
||||
Reference in New Issue
Block a user