Lock the registration email to the invite only when the invite is redeemable #82
@@ -78,7 +78,7 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
|
||||
## Flow (invite mode)
|
||||
1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created storing the token's SHA-256 hash, and the registration link (with the raw token) is shown **once** in a notice. To re-send a lost link, revoke and re-invite.
|
||||
2. The invitee opens `[us_student_register]` with the token (`?us_invite=<token>`); the lookup hashes the submitted token and matches it against the stored hash.
|
||||
3. The form pre-fills the email and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox.
|
||||
3. The form shows the invited email **pre-filled and read-only** (the server always uses the invite's address on submit, so a tampered value is ignored) and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. A token that is no longer redeemable (expired / accepted / revoked) renders the normal editable email field instead when open registration is on.
|
||||
4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in.
|
||||
|
||||
## Flow (self-approval mode)
|
||||
|
||||
@@ -45,6 +45,12 @@ class RegistrationPage {
|
||||
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
|
||||
$open = $this->settings->openRegistrationEnabled();
|
||||
|
||||
// Only a redeemable invite fixes the form's email to the invited address.
|
||||
// A stale token (expired / accepted / revoked) with open registration on
|
||||
// must fall back to the normal editable email field, not show — and then
|
||||
// fail to submit — the stale invite's address.
|
||||
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
|
||||
|
||||
$error = '';
|
||||
$successType = '';
|
||||
|
||||
@@ -65,7 +71,7 @@ class RegistrationPage {
|
||||
$loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) );
|
||||
|
||||
$policyForms = $this->signupPolicies();
|
||||
$canRegister = $open || ( null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) ) );
|
||||
$canRegister = $open || $inviteValid;
|
||||
|
||||
ob_start();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/register-page.php';
|
||||
|
||||
@@ -7,6 +7,7 @@ if (! defined('ABSPATH')) {
|
||||
|
||||
/**
|
||||
* @var \Unsupervised\Schedular\Auth\Invite|null $invite
|
||||
* @var bool $inviteValid Whether $invite can still be redeemed — only then is the email fixed.
|
||||
* @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.
|
||||
@@ -43,7 +44,7 @@ if (! defined('ABSPATH')) {
|
||||
|
||||
<p>
|
||||
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
|
||||
<?php if ($invite !== null) : ?>
|
||||
<?php if ($inviteValid && $invite !== null) : ?>
|
||||
<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>
|
||||
|
||||
@@ -56,8 +56,9 @@ class RegistrationPageTest extends TestCase
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
$_REQUEST = [];
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
@@ -176,6 +177,47 @@ class RegistrationPageTest extends TestCase
|
||||
self::assertStringContainsString('<form', $html);
|
||||
}
|
||||
|
||||
public function testValidInviteRendersEmailPrefilledAndLocked(): void
|
||||
{
|
||||
$_REQUEST = ['us_invite' => 'raw-token'];
|
||||
$this->stubRenderContext();
|
||||
|
||||
$invite = new Invite(email: '[email protected]', token: 'hash', createdAt: '2024-01-01 00:00:00', id: 9);
|
||||
$this->ctx['invites']->shouldReceive('findByToken')
|
||||
->once()
|
||||
->with(Invite::hashToken('raw-token'))
|
||||
->andReturn($invite);
|
||||
|
||||
$html = $this->ctx['page']->render([]);
|
||||
|
||||
// The invited address is shown read-only; there is no editable email input.
|
||||
self::assertStringContainsString('value="[email protected]" readonly', $html);
|
||||
self::assertStringNotContainsString('name="email"', $html);
|
||||
}
|
||||
|
||||
public function testStaleInviteWithOpenRegistrationShowsEditableEmail(): void
|
||||
{
|
||||
$_REQUEST = ['us_invite' => 'raw-token'];
|
||||
$this->stubRenderContext();
|
||||
|
||||
// Already-redeemed invite: not acceptable, so the open-registration form
|
||||
// must collect an email rather than showing the stale locked address.
|
||||
$invite = new Invite(
|
||||
email: '[email protected]',
|
||||
token: 'hash',
|
||||
status: Invite::STATUS_ACCEPTED,
|
||||
createdAt: '2024-01-01 00:00:00',
|
||||
id: 9
|
||||
);
|
||||
$this->ctx['invites']->shouldReceive('findByToken')->once()->andReturn($invite);
|
||||
|
||||
$html = $this->ctx['page']->render([]);
|
||||
|
||||
self::assertStringContainsString('name="email"', $html);
|
||||
self::assertStringNotContainsString('[email protected]', $html);
|
||||
self::assertStringNotContainsString('readonly', $html);
|
||||
}
|
||||
|
||||
public function testRejectsWhenARequiredPolicyIsUnaccepted(): void
|
||||
{
|
||||
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
||||
|
||||
Reference in New Issue
Block a user