diff --git a/app/Actions/ConnectGitHubAccount.php b/app/Actions/ConnectGitHubAccount.php new file mode 100644 index 000000000..a05eb80d0 --- /dev/null +++ b/app/Actions/ConnectGitHubAccount.php @@ -0,0 +1,21 @@ +update([ + 'github_id' => $socialiteUser->getId(), + 'github_username' => $socialiteUser->getNickname(), + ]); + + dispatch(new UpdateUserIdenticonStatus($user)); + } +} diff --git a/app/Actions/DisconnectGitHubAccount.php b/app/Actions/DisconnectGitHubAccount.php new file mode 100644 index 000000000..15373a9ad --- /dev/null +++ b/app/Actions/DisconnectGitHubAccount.php @@ -0,0 +1,17 @@ +update([ + 'github_id' => null, + 'github_username' => null, + 'github_has_identicon' => false, + ]); + } +} diff --git a/app/Http/Controllers/Auth/GitHubController.php b/app/Http/Controllers/Auth/GitHubController.php index cb8fa20d0..849779859 100644 --- a/app/Http/Controllers/Auth/GitHubController.php +++ b/app/Http/Controllers/Auth/GitHubController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Auth; +use App\Actions\ConnectGitHubAccount; use App\Http\Controllers\Controller; use App\Jobs\UpdateProfile; use App\Models\User; @@ -28,7 +29,7 @@ public function redirectToProvider() /** * Obtain the user information from GitHub. */ - public function handleProviderCallback() + public function handleProviderCallback(ConnectGitHubAccount $connectGitHubAccount) { try { $socialiteUser = $this->getSocialiteUser(); @@ -42,6 +43,27 @@ public function handleProviderCallback() return $socialiteUser; } + $isConnectingAttempt = session()->pull('settings.github.connect.intended', false); + + if ($isConnectingAttempt) { + $currentUser = auth()->user(); + + // Check if the GitHub account is already connected to another user. + $existingUser = User::where('github_id', $socialiteUser->getId()) + ->where('id', '!=', $currentUser->id) + ->first(); + + if ($existingUser) { + $this->error('This GitHub account is already connected to another user.'); + } else { + $connectGitHubAccount($currentUser, $socialiteUser); + + $this->success('Your GitHub account has been connected.'); + } + + return redirect(route('settings.profile')); + } + try { $user = User::findByGitHubId($socialiteUser->getId()); } catch (ModelNotFoundException $exception) { diff --git a/app/Http/Controllers/Settings/GitHubAccountController.php b/app/Http/Controllers/Settings/GitHubAccountController.php new file mode 100644 index 000000000..8465b62f1 --- /dev/null +++ b/app/Http/Controllers/Settings/GitHubAccountController.php @@ -0,0 +1,40 @@ +middleware(Authenticate::class); + } + + public function connect(): RedirectResponse + { + session()->put('settings.github.connect.intended', true); + + return redirect(route('login.github')); + } + + public function disconnect(DisconnectGitHubAccount $disconnectGitHubAccount): RedirectResponse + { + $user = auth()->user(); + + if (! $user->password) { + $this->error('You must set a password before disconnecting your GitHub account, otherwise, you will not be able to log in again.'); + + return redirect(route('settings.profile')); + } + + $disconnectGitHubAccount($user); + + $this->success('Your GitHub account has been disconnected.'); + + return redirect(route('settings.profile')); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 1914dbf3f..87e841cfc 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -118,6 +118,11 @@ public function githubUsername(): string return $this->github_username ?? ''; } + public function hasConnectedGitHubAccount(): bool + { + return ! is_null($this->githubId()); + } + public function hasIdenticon(): bool { return (bool) $this->github_has_identicon; diff --git a/resources/views/users/settings/github.blade.php b/resources/views/users/settings/github.blade.php new file mode 100644 index 000000000..bbe4f3523 --- /dev/null +++ b/resources/views/users/settings/github.blade.php @@ -0,0 +1,61 @@ +@title('GitHub') + +
+
+
+
+

+ GitHub Account +

+ +

+ Connect your GitHub account to keep your profile for easy login and avatar sync. +

+
+ + @if (Auth::user()->hasConnectedGitHubAccount()) +
+ + + @if (Auth::user()->password) + + + Disconnect GitHub + + + @else +

+ You must set a password before disconnecting your GitHub account, otherwise, you will not be able to log in again. +

+ @endif +
+ @else +
+

+ Connecting your GitHub account will automatically populate your GitHub username and use your + GitHub profile image. +

+
+ @endif +
+ + @unless (Auth::user()->hasConnectedGitHubAccount()) + +
+ + Connect GitHub + +
+
+ @endunless +
+
diff --git a/resources/views/users/settings/settings.blade.php b/resources/views/users/settings/settings.blade.php index 4ae576947..2c8ff5fba 100644 --- a/resources/views/users/settings/settings.blade.php +++ b/resources/views/users/settings/settings.blade.php @@ -25,7 +25,13 @@ Password - + + + GitHub + + API Tokens @@ -45,6 +51,7 @@
@include('users.settings.profile') @include('users.settings.password') + @include('users.settings.github') @include('users.settings.api_tokens') @include('users.settings.notification_settings') @include('users.settings.blocked') diff --git a/routes/web.php b/routes/web.php index f8b42d2c4..5c7e652f5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -19,6 +19,7 @@ use App\Http\Controllers\ReplyAbleController; use App\Http\Controllers\ReplyController; use App\Http\Controllers\Settings\ApiTokenController; +use App\Http\Controllers\Settings\GitHubAccountController; use App\Http\Controllers\Settings\NotificationSettingsController; use App\Http\Controllers\Settings\PasswordController; use App\Http\Controllers\Settings\ProfileController as ProfileSettingsController; @@ -79,6 +80,8 @@ Route::put('settings', [ProfileSettingsController::class, 'update'])->name('settings.profile.update'); Route::delete('settings', [ProfileSettingsController::class, 'destroy'])->name('settings.profile.delete'); Route::put('settings/password', [PasswordController::class, 'update'])->name('settings.password.update'); +Route::post('settings/github/connect', [GitHubAccountController::class, 'connect'])->name('settings.github.connect'); +Route::post('settings/github/disconnect', [GitHubAccountController::class, 'disconnect'])->name('settings.github.disconnect'); Route::put('settings/users/{username}/unblock', UnblockUserSettingsController::class)->name('settings.users.unblock'); Route::post('settings/api-tokens', [ApiTokenController::class, 'store'])->name('settings.api-tokens.store'); Route::delete('settings/api-tokens', [ApiTokenController::class, 'destroy'])->name('settings.api-tokens.delete'); diff --git a/tests/Feature/GitHubAccountSettingTest.php b/tests/Feature/GitHubAccountSettingTest.php new file mode 100644 index 000000000..e2305d2f2 --- /dev/null +++ b/tests/Feature/GitHubAccountSettingTest.php @@ -0,0 +1,122 @@ +login(); + + $response = $this->actingAs($user)->post('/settings/github/connect'); + + $response->assertRedirect(route('login.github')); + + expect(session('settings.github.connect.intended'))->toBeTrue(); +}); + +test('users can disconnect their GitHub account from settings', function () { + $user = $this->login([ + 'github_id' => '11405387', + 'github_username' => 'theHocineSaad', + 'github_has_identicon' => true, + ]); + + $response = $this->actingAs($user)->post('/settings/github/disconnect'); + + $response->assertRedirect(route('settings.profile')); + $response->assertSessionHas('success', 'Your GitHub account has been disconnected.'); + + $user->refresh(); + + expect($user->github_id)->toBeNull(); + expect($user->github_username)->toBeNull(); + expect($user->github_has_identicon)->toBeFalse(); +}); + +test('users can connect their GitHub account after returning from GitHub', function () { + Queue::fake(); + + $user = $this->login([ + 'github_id' => null, + 'github_username' => null, + ]); + + $socialiteUser = fakeSocialiteUser('11405387', 'theHocineSaad'); + + mockGitHubProvider($socialiteUser); + + $this->withSession(['settings.github.connect.intended' => true]); + + $response = $this->actingAs($user)->get('/auth/github'); + + $response->assertRedirect(route('settings.profile')); + $response->assertSessionHas('success', 'Your GitHub account has been connected.'); + + $user->refresh(); + + expect($user->github_id)->toBe('11405387'); + expect($user->github_username)->toBe('theHocineSaad'); + + Queue::assertPushed(UpdateUserIdenticonStatus::class); +}); + +test('users cannot connect a GitHub account that belongs to another user', function () { + Queue::fake(); + + User::factory()->create([ + 'github_id' => '11405387', + 'github_username' => 'theHocineSaad', + ]); + + $user = $this->login([ + 'github_id' => null, + 'github_username' => null, + ]); + + $socialiteUser = fakeSocialiteUser('11405387', 'theHocineSaad'); + + mockGitHubProvider($socialiteUser); + + $this->withSession(['settings.github.connect.intended' => true]); + + $response = $this->actingAs($user)->get('/auth/github'); + + $response->assertRedirect(route('settings.profile')); + $response->assertSessionHas('error', 'This GitHub account is already connected to another user.'); + + $user->refresh(); + + expect($user->github_id)->toBeNull(); + expect($user->github_username)->toBeNull(); + + Queue::assertNothingPushed(); +}); + +function fakeSocialiteUser(string $id, string $nickname): SocialiteUser +{ + return tap(new SocialiteUser()) + ->setRaw([ + 'id' => $id, + 'login' => $nickname, + ]) + ->map([ + 'id' => $id, + 'nickname' => $nickname, + ]); +} + +function mockGitHubProvider(SocialiteUser $user): void +{ + $provider = Mockery::mock(Provider::class); + $provider->shouldReceive('user')->once()->andReturn($user); + + Socialite::shouldReceive('driver')->once()->with('github')->andReturn($provider); +}