diff --git a/app/Livewire/Profile/UpdatePasswordForm.php b/app/Livewire/Profile/UpdatePasswordForm.php new file mode 100644 index 0000000..0c01ff1 --- /dev/null +++ b/app/Livewire/Profile/UpdatePasswordForm.php @@ -0,0 +1,50 @@ +resetErrorBag(); + + $user = Auth::user(); + + if ($user->password !== null) { + parent::updatePassword($updater); + } else { + + // User has a null password. Allow them to set a new password without their current password. + Validator::make($this->state, [ + 'password' => $this->passwordRules(), + ])->validateWithBag('updatePassword'); + + auth()->user()->forceFill([ + 'password' => Hash::make($this->state['password']), + ])->save(); + + $this->state = [ + 'current_password' => '', + 'password' => '', + 'password_confirmation' => '', + ]; + + $this->dispatch('saved'); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 83058d0..d1ee748 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Livewire\Profile\UpdatePasswordForm; use App\Models\Mod; use App\Models\ModDependency; use App\Models\ModVersion; @@ -17,6 +18,7 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Number; use Illuminate\Support\ServiceProvider; +use Livewire\Livewire; use SocialiteProviders\Discord\Provider; use SocialiteProviders\Manager\SocialiteWasCalled; @@ -45,6 +47,9 @@ class AppServiceProvider extends ServiceProvider $this->registerNumberMacros(); $this->registerCarbonMacros(); + // Register Livewire component overrides. + $this->registerLivewireOverrides(); + // This gate determines who can access the Pulse dashboard. Gate::define('viewPulse', function (User $user) { return $user->isAdmin(); @@ -100,4 +105,12 @@ class AppServiceProvider extends ServiceProvider return $date->format('M jS, g:i A'); }); } + + /** + * Register Livewire component overrides. + */ + private function registerLivewireOverrides(): void + { + Livewire::component('profile.update-password-form', UpdatePasswordForm::class); + } } diff --git a/database/factories/OAuthConnectionFactory.php b/database/factories/OAuthConnectionFactory.php index 7338ea3..a391720 100644 --- a/database/factories/OAuthConnectionFactory.php +++ b/database/factories/OAuthConnectionFactory.php @@ -3,7 +3,9 @@ namespace Database\Factories; use App\Models\OAuthConnection; +use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; class OAuthConnectionFactory extends Factory { @@ -15,7 +17,13 @@ class OAuthConnectionFactory extends Factory public function definition(): array { return [ - // + 'user_id' => User::factory(), + 'provider_name' => $this->faker->randomElement(['discord', 'google', 'facebook']), + 'provider_id' => (string) $this->faker->unique()->numberBetween(100000, 999999), + 'token' => Str::random(40), + 'refresh_token' => Str::random(40), + 'created_at' => now(), + 'updated_at' => now(), ]; } } diff --git a/resources/views/profile/update-password-form.blade.php b/resources/views/profile/update-password-form.blade.php index fc1ebf7..2879b59 100644 --- a/resources/views/profile/update-password-form.blade.php +++ b/resources/views/profile/update-password-form.blade.php @@ -8,11 +8,18 @@ -
- - - -
+ + @if (auth()->user()->password === null) +
+

{{ __('Your account does not have a password set. We recommend setting a password so that you can recover your account if you need to.') }}

+
+ @else +
+ + + +
+ @endif
diff --git a/tests/Feature/User/OAuthAccountTest.php b/tests/Feature/User/OAuthAccountTest.php new file mode 100644 index 0000000..f1689da --- /dev/null +++ b/tests/Feature/User/OAuthAccountTest.php @@ -0,0 +1,172 @@ +shouldReceive('getId')->andReturn('provider-user-id'); + $socialiteUser->shouldReceive('getEmail')->andReturn('newuser@example.com'); + $socialiteUser->shouldReceive('getName')->andReturn('New User'); + $socialiteUser->shouldReceive('getNickname')->andReturn(null); + $socialiteUser->shouldReceive('getAvatar')->andReturn('avatar-url'); + $socialiteUser->token = 'access-token'; + $socialiteUser->refreshToken = 'refresh-token'; + + // Mock Socialite facade + Socialite::shouldReceive('driver->user')->andReturn($socialiteUser); + + // Hit the callback route + $response = $this->get('/login/discord/callback'); + + // Assert that the user was created + $user = User::where('email', 'newuser@example.com')->first(); + expect($user)->not->toBeNull() + ->and($user->name)->toBe('New User'); + + // Assert that the OAuth provider was attached + $oAuthConnection = $user->oAuthConnections()->whereProvider('discord')->first(); + expect($oAuthConnection)->not->toBeNull() + ->and($oAuthConnection->provider_id)->toBe('provider-user-id'); + + // Assert the user is authenticated + $this->assertAuthenticatedAs($user); + + // Assert redirect to dashboard + $response->assertRedirect(route('dashboard')); +}); + +it('attaches a new OAuth provider to an existing user when logging in via OAuth', function () { + // Create an existing user + $user = User::factory()->create([ + 'email' => 'existinguser@example.com', + 'name' => 'Existing User', + 'password' => Hash::make('password123'), + ]); + + // Mock the Socialite user + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('new-provider-user-id'); + $socialiteUser->shouldReceive('getEmail')->andReturn('existinguser@example.com'); + $socialiteUser->shouldReceive('getName')->andReturn('Existing User Updated'); + $socialiteUser->shouldReceive('getNickname')->andReturn(null); + $socialiteUser->shouldReceive('getAvatar')->andReturn('new-avatar-url'); + $socialiteUser->token = 'new-access-token'; + $socialiteUser->refreshToken = 'new-refresh-token'; + + // Mock Socialite facade + Socialite::shouldReceive('driver->user')->andReturn($socialiteUser); + + // Hit the callback route + $response = $this->get('/login/discord/callback'); + + // Refresh user data + $user->refresh(); + + // Assert that the username was not updated + expect($user->name)->toBe('Existing User') + ->and($user->name)->not->toBe('Existing User Updated'); + + // Assert that the new OAuth provider was attached + $oauthConnection = $user->oAuthConnections()->whereProvider('discord')->first(); + expect($oauthConnection)->not->toBeNull() + ->and($oauthConnection->provider_id)->toBe('new-provider-user-id'); + + // Assert the user is authenticated + $this->assertAuthenticatedAs($user); + + // Assert redirect to dashboard + $response->assertRedirect(route('dashboard')); +}); + +it('hides the current password field when the user has no password', function () { + // Create a user with no password + $user = User::factory()->create([ + 'password' => null, + ]); + + $this->actingAs($user); + + // Visit the profile page + $response = $this->get('/user/profile'); + $response->assertStatus(200); + + // Assert that the current password field is not displayed + $response->assertDontSee(__('Current Password')); +}); + +it('shows the current password field when the user has a password', function () { + // Create a user with a password + $user = User::factory()->create([ + 'password' => bcrypt('password123'), + ]); + + $this->actingAs($user); + + // Visit the profile page + $response = $this->get('/user/profile'); + $response->assertStatus(200); + + // Assert that the current password field is displayed + $response->assertSee(__('Current Password')); +}); + +it('allows a user without a password to set a new password without entering the current password', function () { + // Create a user with a NULL password + $user = User::factory()->create([ + 'password' => null, + ]); + + $this->actingAs($user); + + // Test the Livewire component + Livewire::test(UpdatePasswordForm::class) + ->set('state.password', 'newpassword123') + ->set('state.password_confirmation', 'newpassword123') + ->call('updatePassword') + ->assertHasNoErrors(); + + // Refresh user data + $user->refresh(); + + // Assert that the password is now set + expect(Hash::check('newpassword123', $user->password))->toBeTrue(); +}); + +it('requires a user with a password to enter the current password to set a new password', function () { + $user = User::factory()->create([ + 'password' => Hash::make('oldpassword'), + ]); + + $this->actingAs($user); + + // Without current password + Livewire::test(UpdatePasswordForm::class) + ->set('state.password', 'newpassword123') + ->set('state.password_confirmation', 'newpassword123') + ->call('updatePassword') + ->assertHasErrors(['current_password' => 'required']); + + // With incorrect current password + Livewire::test(UpdatePasswordForm::class) + ->set('state.current_password', 'wrongpassword') + ->set('state.password', 'newpassword123') + ->set('state.password_confirmation', 'newpassword123') + ->call('updatePassword') + ->assertHasErrors(['current_password']); + + // With correct current password + Livewire::test(UpdatePasswordForm::class) + ->set('state.current_password', 'oldpassword') + ->set('state.password', 'newpassword123') + ->set('state.password_confirmation', 'newpassword123') + ->call('updatePassword') + ->assertHasNoErrors(); + + $user->refresh(); + + expect(Hash::check('newpassword123', $user->password))->toBeTrue(); +});