From 681fc5ae07b7ec42a568770c05168bbf8508e296 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Wed, 22 Jul 2026 10:24:44 -0300 Subject: [PATCH] Lock the registration email to the invite only when the invite is redeemable The register form keyed the read-only, prefilled email off any invite row matching the token. A stale token (expired / accepted / revoked) with open registration on therefore showed the stale invite's address read-only while the submit handler took the open branch and required a posted email the locked field never submits, dead-ending the form. The lock now applies exactly when the invite is acceptable; otherwise the editable field renders. Closes #78 Co-Authored-By: Claude Fable 5 --- docs/features/account-registration.md | 2 +- src/Auth/RegistrationPage.php | 8 ++++- templates/frontend/register-page.php | 3 +- tests/Unit/Auth/RegistrationPageTest.php | 46 ++++++++++++++++++++++-- 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/docs/features/account-registration.md b/docs/features/account-registration.md index b5c0714..8b4da2d 100644 --- a/docs/features/account-registration.md +++ b/docs/features/account-registration.md @@ -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=`); 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) diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php index 48e7cb5..a9f8e5a 100644 --- a/src/Auth/RegistrationPage.php +++ b/src/Auth/RegistrationPage.php @@ -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'; diff --git a/templates/frontend/register-page.php b/templates/frontend/register-page.php index 2ce01ef..172fc17 100644 --- a/templates/frontend/register-page.php +++ b/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')) {

- + diff --git a/tests/Unit/Auth/RegistrationPageTest.php b/tests/Unit/Auth/RegistrationPageTest.php index bb96428..8ba0cb8 100644 --- a/tests/Unit/Auth/RegistrationPageTest.php +++ b/tests/Unit/Auth/RegistrationPageTest.php @@ -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(' 'raw-token']; + $this->stubRenderContext(); + + $invite = new Invite(email: 'invited@b.test', 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="invited@b.test" 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: 'used@b.test', + 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('used@b.test', $html); + self::assertStringNotContainsString('readonly', $html); + } + public function testRejectsWhenARequiredPolicyIsUnaccepted(): void { $_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; -- 2.54.0