Merge pull request 'Add multi-use group invite links with expiry and auto-approval on email confirmation' (#83) from feature/group-invite-links into main
CI / Tests (PHP 8.1) (push) Successful in 38s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m50s
CI / PHPStan (push) Successful in 2m49s
CI / Tests (PHP 8.3) (push) Successful in 2m38s
CI / Build Plugin Zip (push) Successful in 2m47s
CI / Tests (PHP 8.1) (push) Successful in 38s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m50s
CI / PHPStan (push) Successful in 2m49s
CI / Tests (PHP 8.3) (push) Successful in 2m38s
CI / Build Plugin Zip (push) Successful in 2m47s
Reviewed-on: #83
This commit was merged in pull request #83.
This commit is contained in:
@@ -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 ) );
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user