From 3ec2abb309da0e28c8257e13f011071698397c9a Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Tue, 25 Aug 2026 16:38:07 +0200 Subject: [PATCH] Add invite-only registration mode Support configurable on, invite-only, and off registration modes, including case-insensitive invitation checks and test coverage. --- .env.example | 1 + app/Actions/Fortify/CreateNewUser.php | 14 ++++- app/Enums/RegistrationMode.php | 29 ++++++++++ app/Service/InvitationService.php | 7 +++ config/app.php | 2 +- tests/Feature/RegistrationTest.php | 69 +++++++++++++++++++++++ tests/Unit/Enums/RegistrationModeTest.php | 38 +++++++++++++ 7 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 app/Enums/RegistrationMode.php create mode 100644 tests/Unit/Enums/RegistrationModeTest.php diff --git a/.env.example b/.env.example index 44ee8787..5f83d802 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk= APP_DEBUG=true APP_URL=https://solidtime.test APP_FORCE_HTTPS=false +# Supported values: on/true, invite/invite-only, off/false APP_ENABLE_REGISTRATION=true SUPER_ADMINS=admin@example.com PAGINATION_PER_PAGE_DEFAULT=500 diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 3f1ae30f..ae5d6b7f 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace App\Actions\Fortify; +use App\Enums\RegistrationMode; use App\Enums\Weekday; use App\Events\NewsletterRegistered; use App\Models\User; +use App\Service\InvitationService; use App\Service\IpLookup\IpLookupServiceContract; use App\Service\TimezoneService; use App\Service\UserService; @@ -31,13 +33,14 @@ class CreateNewUser implements CreatesNewUsers */ public function create(array $input): User { - if (! config('app.enable_registration')) { + $registrationMode = RegistrationMode::fromConfig(config('app.enable_registration')); + if ($registrationMode === RegistrationMode::Off) { throw ValidationException::withMessages([ 'email' => [__('Registration is disabled.')], ]); } - Validator::make($input, [ + $validated = Validator::make($input, [ 'name' => [ 'required', 'string', @@ -60,6 +63,13 @@ class CreateNewUser implements CreatesNewUsers ], ])->validate(); + if ($registrationMode === RegistrationMode::InviteOnly + && ! app(InvitationService::class)->hasInvitationForEmail((string) $validated['email'])) { + throw ValidationException::withMessages([ + 'email' => [__('Registration is only available to invited users.')], + ]); + } + $timezone = null; if (array_key_exists('timezone', $input) && is_string($input['timezone'])) { if (app(TimezoneService::class)->isValid($input['timezone'])) { diff --git a/app/Enums/RegistrationMode.php b/app/Enums/RegistrationMode.php new file mode 100644 index 00000000..ab56f06b --- /dev/null +++ b/app/Enums/RegistrationMode.php @@ -0,0 +1,29 @@ + self::On, + 'invite', 'invite-only' => self::InviteOnly, + default => self::Off, + }; + } +} diff --git a/app/Service/InvitationService.php b/app/Service/InvitationService.php index 4de14912..17528428 100644 --- a/app/Service/InvitationService.php +++ b/app/Service/InvitationService.php @@ -18,6 +18,13 @@ use Illuminate\Support\Facades\Mail; class InvitationService { + public function hasInvitationForEmail(string $email): bool + { + return OrganizationInvitation::query() + ->whereRaw('lower(email) = ?', [strtolower($email)]) + ->exists(); + } + /** * @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException */ diff --git a/config/app.php b/config/app.php index 64d67991..98eaad10 100644 --- a/config/app.php +++ b/config/app.php @@ -100,7 +100,7 @@ return [ 'force_https' => (bool) env('APP_FORCE_HTTPS', false), - 'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false), + 'enable_registration' => env('APP_ENABLE_REGISTRATION', false), 'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false), diff --git a/tests/Feature/RegistrationTest.php b/tests/Feature/RegistrationTest.php index 1ba1aada..7df4cd92 100644 --- a/tests/Feature/RegistrationTest.php +++ b/tests/Feature/RegistrationTest.php @@ -89,6 +89,75 @@ class RegistrationTest extends TestCaseWithDatabase Event::assertNotDispatched(NewsletterRegistered::class); } + public function test_user_registration_fails_without_an_invitation_if_registration_is_invite_only(): void + { + Config::set('app.enable_registration', 'invite-only'); + + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'terms' => true, + ]); + + $response->assertInvalid([ + 'email' => 'Registration is only available to invited users.', + ]); + $this->assertFalse(User::query()->where('email', 'test@example.com')->exists()); + } + + public function test_invited_user_can_register_if_registration_is_invite_only(): void + { + Config::set('app.enable_registration', 'invite'); + $user = $this->createUserWithPermission(); + OrganizationInvitation::factory() + ->forOrganization($user->organization) + ->role(Role::Employee) + ->accepted() + ->create([ + 'email' => 'Invited.User@example.com', + ]); + + $response = $this->post('/register', [ + 'name' => 'Invited User', + 'email' => 'invited.user@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'terms' => true, + ]); + + $response->assertValid(); + $this->assertAuthenticated(); + $response->assertRedirect(RouteServiceProvider::HOME); + $newUser = User::query()->where('email', 'invited.user@example.com')->firstOrFail(); + $this->assertSame($user->organization->getKey(), $newUser->organizations()->firstOrFail()->getKey()); + } + + public function test_pending_invitation_allows_registration_if_registration_is_invite_only(): void + { + Config::set('app.enable_registration', 'invite-only'); + $user = $this->createUserWithPermission(); + OrganizationInvitation::factory() + ->forOrganization($user->organization) + ->role(Role::Employee) + ->create([ + 'email' => 'test@example.com', + ]); + + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'terms' => true, + ]); + + $response->assertValid(); + $this->assertAuthenticated(); + $response->assertRedirect(RouteServiceProvider::HOME); + } + public function test_new_user_can_not_register_with_likely_invalid_domain(): void { // Act diff --git a/tests/Unit/Enums/RegistrationModeTest.php b/tests/Unit/Enums/RegistrationModeTest.php new file mode 100644 index 00000000..b3b4eea5 --- /dev/null +++ b/tests/Unit/Enums/RegistrationModeTest.php @@ -0,0 +1,38 @@ + + */ + public static function configValueProvider(): array + { + return [ + 'boolean true' => [true, RegistrationMode::On], + 'on' => ['on', RegistrationMode::On], + 'true' => ['true', RegistrationMode::On], + 'invite' => ['invite', RegistrationMode::InviteOnly], + 'invite-only' => ['invite-only', RegistrationMode::InviteOnly], + 'boolean false' => [false, RegistrationMode::Off], + 'off' => ['off', RegistrationMode::Off], + 'false' => ['false', RegistrationMode::Off], + 'unknown' => ['unknown', RegistrationMode::Off], + ]; + } + + #[DataProvider('configValueProvider')] + public function test_registration_mode_is_created_from_config_value(mixed $value, RegistrationMode $expected): void + { + $this->assertSame($expected, RegistrationMode::fromConfig($value)); + } +}