diff --git a/.env.example b/.env.example index 5f83d802..5c0a4fee 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,8 @@ 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 +# Supported values: on, off, invite-only (true/false are supported for backwards compatibility) +APP_ENABLE_REGISTRATION=on 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 ae5d6b7f..7f36236e 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -63,11 +63,18 @@ 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.')], - ]); + if ($registrationMode === RegistrationMode::InviteOnly) { + $invitationService = app(InvitationService::class); + $email = (string) $validated['email']; + if (! $invitationService->hasAcceptedInvitationForEmail($email)) { + $message = $invitationService->hasPendingInvitationForEmail($email) + ? __('Please accept the organization invitation sent to your email address before registering.') + : __('Registration is only available to invited users.'); + + throw ValidationException::withMessages([ + 'email' => [$message], + ]); + } } $timezone = null; diff --git a/app/Enums/RegistrationMode.php b/app/Enums/RegistrationMode.php index ab56f06b..ae002b34 100644 --- a/app/Enums/RegistrationMode.php +++ b/app/Enums/RegistrationMode.php @@ -22,7 +22,7 @@ enum RegistrationMode: string return match (strtolower(trim((string) $value))) { '1', 'on', 'true' => self::On, - 'invite', 'invite-only' => self::InviteOnly, + 'invite-only' => self::InviteOnly, default => self::Off, }; } diff --git a/app/Http/Controllers/Web/OrganizationInvitationController.php b/app/Http/Controllers/Web/OrganizationInvitationController.php index 8d6e6581..55f97115 100644 --- a/app/Http/Controllers/Web/OrganizationInvitationController.php +++ b/app/Http/Controllers/Web/OrganizationInvitationController.php @@ -36,6 +36,7 @@ class OrganizationInvitationController extends Controller } return redirect(route('register')) + ->with('registration_email', $email) ->with('bannerText', __('Please create an account to finish joining the :organization organization.', [ 'organization' => $organization->name, ])) diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 270a1571..7d39862f 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -45,6 +45,7 @@ class FortifyServiceProvider extends ServiceProvider Fortify::registerView(function () { return Inertia::render('Auth/Register', [ + 'email' => session('registration_email', ''), 'terms_url' => config('auth.terms_url'), 'privacy_policy_url' => config('auth.privacy_policy_url'), 'newsletter_consent' => config('auth.newsletter_consent'), diff --git a/app/Service/InvitationService.php b/app/Service/InvitationService.php index 17528428..99df11d4 100644 --- a/app/Service/InvitationService.php +++ b/app/Service/InvitationService.php @@ -18,10 +18,19 @@ use Illuminate\Support\Facades\Mail; class InvitationService { - public function hasInvitationForEmail(string $email): bool + public function hasAcceptedInvitationForEmail(string $email): bool { return OrganizationInvitation::query() ->whereRaw('lower(email) = ?', [strtolower($email)]) + ->whereNotNull('accepted_at') + ->exists(); + } + + public function hasPendingInvitationForEmail(string $email): bool + { + return OrganizationInvitation::query() + ->whereRaw('lower(email) = ?', [strtolower($email)]) + ->whereNull('accepted_at') ->exists(); } diff --git a/config/app.php b/config/app.php index 98eaad10..8c07d081 100644 --- a/config/app.php +++ b/config/app.php @@ -100,7 +100,7 @@ return [ 'force_https' => (bool) env('APP_FORCE_HTTPS', false), - 'enable_registration' => env('APP_ENABLE_REGISTRATION', false), + 'enable_registration' => env('APP_ENABLE_REGISTRATION', 'off'), 'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false), diff --git a/e2e/invitation-registration-prefill.spec.ts b/e2e/invitation-registration-prefill.spec.ts new file mode 100644 index 00000000..eaef7f2d --- /dev/null +++ b/e2e/invitation-registration-prefill.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '../playwright/fixtures'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { getInvitationAcceptUrl } from './utils/mailpit'; + +test('prefills the registration email after accepting an invitation', async ({ page, browser }) => { + const memberId = `${Date.now()}-${Math.floor(Math.random() * 100000)}`; + const memberEmail = `prefill-${memberId}@invitation.test`; + + await page.goto(PLAYWRIGHT_BASE_URL + '/members'); + await page.getByRole('button', { name: 'Invite Member' }).click(); + await page.getByPlaceholder('Member Email').fill(memberEmail); + await page.getByRole('button', { name: 'Employee' }).click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/invitations') && + response.request().method() === 'POST' && + response.status() === 204 + ), + page.getByRole('button', { name: 'Invite Member', exact: true }).click(), + ]); + + const inviteeContext = await browser.newContext(); + const inviteePage = await inviteeContext.newPage(); + const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail); + await inviteePage.goto(acceptUrl); + await inviteePage.waitForURL(/\/register$/); + + await expect(inviteePage.getByLabel('Email')).toHaveValue(memberEmail); + + await inviteeContext.close(); +}); diff --git a/resources/js/Pages/Auth/Register.vue b/resources/js/Pages/Auth/Register.vue index ce3de8e0..79b513c2 100644 --- a/resources/js/Pages/Auth/Register.vue +++ b/resources/js/Pages/Auth/Register.vue @@ -8,9 +8,13 @@ import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import TextInput from '@/packages/ui/src/Input/TextInput.vue'; +const props = defineProps<{ + email: string; +}>(); + const form = useForm({ name: '', - email: '', + email: props.email, password: '', password_confirmation: '', terms: false, diff --git a/tests/Feature/RegistrationTest.php b/tests/Feature/RegistrationTest.php index 7df4cd92..b588756a 100644 --- a/tests/Feature/RegistrationTest.php +++ b/tests/Feature/RegistrationTest.php @@ -22,6 +22,13 @@ use TiMacDonald\Log\LogEntry; class RegistrationTest extends TestCaseWithDatabase { + protected function setUp(): void + { + parent::setUp(); + + Config::set('app.enable_registration', 'on'); + } + public function test_registration_screen_can_be_rendered(): void { if (! Features::enabled(Features::registration())) { @@ -109,7 +116,7 @@ class RegistrationTest extends TestCaseWithDatabase public function test_invited_user_can_register_if_registration_is_invite_only(): void { - Config::set('app.enable_registration', 'invite'); + Config::set('app.enable_registration', 'invite-only'); $user = $this->createUserWithPermission(); OrganizationInvitation::factory() ->forOrganization($user->organization) @@ -134,7 +141,7 @@ class RegistrationTest extends TestCaseWithDatabase $this->assertSame($user->organization->getKey(), $newUser->organizations()->firstOrFail()->getKey()); } - public function test_pending_invitation_allows_registration_if_registration_is_invite_only(): void + public function test_user_must_accept_pending_invitation_before_registration_if_registration_is_invite_only(): void { Config::set('app.enable_registration', 'invite-only'); $user = $this->createUserWithPermission(); @@ -153,9 +160,11 @@ class RegistrationTest extends TestCaseWithDatabase 'terms' => true, ]); - $response->assertValid(); - $this->assertAuthenticated(); - $response->assertRedirect(RouteServiceProvider::HOME); + $response->assertInvalid([ + 'email' => 'Please accept the organization invitation sent to your email address before registering.', + ]); + $this->assertGuest(); + $this->assertFalse(User::query()->where('email', 'test@example.com')->exists()); } public function test_new_user_can_not_register_with_likely_invalid_domain(): void diff --git a/tests/Unit/Endpoint/Web/OrganizationInvitationEndpointTest.php b/tests/Unit/Endpoint/Web/OrganizationInvitationEndpointTest.php index 87de0a00..fc978fc6 100644 --- a/tests/Unit/Endpoint/Web/OrganizationInvitationEndpointTest.php +++ b/tests/Unit/Endpoint/Web/OrganizationInvitationEndpointTest.php @@ -38,6 +38,7 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract $response->assertRedirect(route('register')); $response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.'); $response->assertSessionHas('bannerStyle', 'info'); + $response->assertSessionHas('registration_email', strtolower($invitation->email)); $invitation->refresh(); $this->assertNotNull($invitation->accepted_at); } @@ -64,6 +65,7 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract $response->assertRedirect(route('register')); $response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.'); $response->assertSessionHas('bannerStyle', 'info'); + $response->assertSessionHas('registration_email', strtolower($invitation->email)); $invitation->refresh(); $this->assertNotNull($invitation->accepted_at); } diff --git a/tests/Unit/Enums/RegistrationModeTest.php b/tests/Unit/Enums/RegistrationModeTest.php index b3b4eea5..ae085092 100644 --- a/tests/Unit/Enums/RegistrationModeTest.php +++ b/tests/Unit/Enums/RegistrationModeTest.php @@ -21,11 +21,11 @@ class RegistrationModeTest extends TestCase '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], + 'unsupported invite alias' => ['invite', RegistrationMode::Off], 'unknown' => ['unknown', RegistrationMode::Off], ]; }