Skip to content

Commit d247835

Browse files
Add GitHub account connection and disconnection functionality (#1375)
1 parent f059480 commit d247835

File tree

9 files changed

+300
-2
lines changed

9 files changed

+300
-2
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace App\Actions;
4+
5+
use App\Jobs\UpdateUserIdenticonStatus;
6+
use App\Models\User;
7+
use Laravel\Socialite\Two\User as SocialiteUser;
8+
use function dispatch;
9+
10+
final class ConnectGitHubAccount
11+
{
12+
public function __invoke(User $user, SocialiteUser $socialiteUser): void
13+
{
14+
$user->update([
15+
'github_id' => $socialiteUser->getId(),
16+
'github_username' => $socialiteUser->getNickname(),
17+
]);
18+
19+
dispatch(new UpdateUserIdenticonStatus($user));
20+
}
21+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace App\Actions;
4+
5+
use App\Models\User;
6+
7+
final class DisconnectGitHubAccount
8+
{
9+
public function __invoke(User $user): void
10+
{
11+
$user->update([
12+
'github_id' => null,
13+
'github_username' => null,
14+
'github_has_identicon' => false,
15+
]);
16+
}
17+
}

app/Http/Controllers/Auth/GitHubController.php

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Http\Controllers\Auth;
44

5+
use App\Actions\ConnectGitHubAccount;
56
use App\Http\Controllers\Controller;
67
use App\Jobs\UpdateProfile;
78
use App\Models\User;
@@ -28,7 +29,7 @@ public function redirectToProvider()
2829
/**
2930
* Obtain the user information from GitHub.
3031
*/
31-
public function handleProviderCallback()
32+
public function handleProviderCallback(ConnectGitHubAccount $connectGitHubAccount)
3233
{
3334
try {
3435
$socialiteUser = $this->getSocialiteUser();
@@ -42,6 +43,27 @@ public function handleProviderCallback()
4243
return $socialiteUser;
4344
}
4445

46+
$isConnectingAttempt = session()->pull('settings.github.connect.intended', false);
47+
48+
if ($isConnectingAttempt) {
49+
$currentUser = auth()->user();
50+
51+
// Check if the GitHub account is already connected to another user.
52+
$existingUser = User::where('github_id', $socialiteUser->getId())
53+
->where('id', '!=', $currentUser->id)
54+
->first();
55+
56+
if ($existingUser) {
57+
$this->error('This GitHub account is already connected to another user.');
58+
} else {
59+
$connectGitHubAccount($currentUser, $socialiteUser);
60+
61+
$this->success('Your GitHub account has been connected.');
62+
}
63+
64+
return redirect(route('settings.profile'));
65+
}
66+
4567
try {
4668
$user = User::findByGitHubId($socialiteUser->getId());
4769
} catch (ModelNotFoundException $exception) {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Settings;
4+
5+
use App\Actions\DisconnectGitHubAccount;
6+
use App\Http\Controllers\Controller;
7+
use Illuminate\Auth\Middleware\Authenticate;
8+
use Illuminate\Http\RedirectResponse;
9+
10+
final class GitHubAccountController extends Controller
11+
{
12+
public function __construct()
13+
{
14+
$this->middleware(Authenticate::class);
15+
}
16+
17+
public function connect(): RedirectResponse
18+
{
19+
session()->put('settings.github.connect.intended', true);
20+
21+
return redirect(route('login.github'));
22+
}
23+
24+
public function disconnect(DisconnectGitHubAccount $disconnectGitHubAccount): RedirectResponse
25+
{
26+
$user = auth()->user();
27+
28+
if (! $user->password) {
29+
$this->error('You must set a password before disconnecting your GitHub account, otherwise, you will not be able to log in again.');
30+
31+
return redirect(route('settings.profile'));
32+
}
33+
34+
$disconnectGitHubAccount($user);
35+
36+
$this->success('Your GitHub account has been disconnected.');
37+
38+
return redirect(route('settings.profile'));
39+
}
40+
}

app/Models/User.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ public function githubUsername(): string
118118
return $this->github_username ?? '';
119119
}
120120

121+
public function hasConnectedGitHubAccount(): bool
122+
{
123+
return ! is_null($this->githubId());
124+
}
125+
121126
public function hasIdenticon(): bool
122127
{
123128
return (bool) $this->github_has_identicon;
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
@title('GitHub')
2+
3+
<section aria-labelledby="github_settings_heading" class="mt-6">
4+
<div class="shadow-sm sm:rounded-md sm:overflow-hidden">
5+
<div class="bg-white py-6 px-4 space-y-6 sm:p-6">
6+
<div>
7+
<h2 id="github_settings_heading" class="text-lg leading-6 font-medium text-gray-900">
8+
GitHub Account
9+
</h2>
10+
11+
<p class="mt-1 text-sm leading-5 text-gray-500">
12+
Connect your GitHub account to keep your profile for easy login and avatar sync.
13+
</p>
14+
</div>
15+
16+
@if (Auth::user()->hasConnectedGitHubAccount())
17+
<div class="flex items-center justify-between flex-wrap gap-4">
18+
<div class="space-y-1">
19+
<span class="block text-sm font-medium text-gray-700">
20+
Connected as
21+
</span>
22+
23+
<a href="https://github.com/{{ Auth::user()->githubUsername() }}" class="text-lio-700 font-semibold"
24+
target="_blank" rel="noopener">
25+
{{ '@' . Auth::user()->githubUsername() }}
26+
</a>
27+
</div>
28+
29+
@if (Auth::user()->password)
30+
<x-forms.form method="POST" action="{{ route('settings.github.disconnect') }}">
31+
<x-buttons.danger-button type="submit">
32+
Disconnect GitHub
33+
</x-buttons.danger-button>
34+
</x-forms.form>
35+
@else
36+
<p class="text-sm text-red-600 mt-2">
37+
You must set a password before disconnecting your GitHub account, otherwise, you will not be able to log in again.
38+
</p>
39+
@endif
40+
</div>
41+
@else
42+
<div class="flex items-center justify-between flex-wrap gap-4">
43+
<p class="text-sm text-gray-600">
44+
Connecting your GitHub account will automatically populate your GitHub username and use your
45+
GitHub profile image.
46+
</p>
47+
</div>
48+
@endif
49+
</div>
50+
51+
@unless (Auth::user()->hasConnectedGitHubAccount())
52+
<x-forms.form id="github_settings_form" method="POST" action="{{ route('settings.github.connect') }}">
53+
<div class="px-4 py-3 bg-gray-50 text-right sm:px-6">
54+
<x-buttons.primary-button type="submit">
55+
Connect GitHub
56+
</x-buttons.primary-button>
57+
</div>
58+
</x-forms.form>
59+
@endunless
60+
</div>
61+
</section>

resources/views/users/settings/settings.blade.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@
2525
<x-heroicon-o-key class="text-gray-400 shrink-0 -ml-1 mr-3 h-6 w-6" />
2626
<span class="truncate">Password</span>
2727
</a>
28-
<a href="#api_token_settings_heading" class="text-gray-600 hover:bg-gray-50 hover:text-gray-900 flex items-center px-3 py-2 text-sm font-medium rounded-md">
28+
<a href="#github_settings_heading"
29+
class="text-gray-600 hover:bg-gray-50 hover:text-gray-900 flex items-center px-3 py-2 text-sm font-medium rounded-md">
30+
<x-si-github class="text-gray-400 shrink-0 -ml-1 mr-3 h-6 w-6" />
31+
<span class="truncate">GitHub</span>
32+
</a>
33+
<a href="#api_token_settings_heading"
34+
class="text-gray-600 hover:bg-gray-50 hover:text-gray-900 flex items-center px-3 py-2 text-sm font-medium rounded-md">
2935
<x-heroicon-o-code-bracket class="text-gray-400 shrink-0 -ml-1 mr-3 h-6 w-6" />
3036
<span class="truncate">API Tokens</span>
3137
</a>
@@ -45,6 +51,7 @@
4551
<div class="mt-10 lg:mt-0 sm:px-6 lg:px-0 lg:col-span-3">
4652
@include('users.settings.profile')
4753
@include('users.settings.password')
54+
@include('users.settings.github')
4855
@include('users.settings.api_tokens')
4956
@include('users.settings.notification_settings')
5057
@include('users.settings.blocked')

routes/web.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use App\Http\Controllers\ReplyAbleController;
2020
use App\Http\Controllers\ReplyController;
2121
use App\Http\Controllers\Settings\ApiTokenController;
22+
use App\Http\Controllers\Settings\GitHubAccountController;
2223
use App\Http\Controllers\Settings\NotificationSettingsController;
2324
use App\Http\Controllers\Settings\PasswordController;
2425
use App\Http\Controllers\Settings\ProfileController as ProfileSettingsController;
@@ -79,6 +80,8 @@
7980
Route::put('settings', [ProfileSettingsController::class, 'update'])->name('settings.profile.update');
8081
Route::delete('settings', [ProfileSettingsController::class, 'destroy'])->name('settings.profile.delete');
8182
Route::put('settings/password', [PasswordController::class, 'update'])->name('settings.password.update');
83+
Route::post('settings/github/connect', [GitHubAccountController::class, 'connect'])->name('settings.github.connect');
84+
Route::post('settings/github/disconnect', [GitHubAccountController::class, 'disconnect'])->name('settings.github.disconnect');
8285
Route::put('settings/users/{username}/unblock', UnblockUserSettingsController::class)->name('settings.users.unblock');
8386
Route::post('settings/api-tokens', [ApiTokenController::class, 'store'])->name('settings.api-tokens.store');
8487
Route::delete('settings/api-tokens', [ApiTokenController::class, 'destroy'])->name('settings.api-tokens.delete');
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
use App\Jobs\UpdateUserIdenticonStatus;
4+
use App\Models\User;
5+
use Illuminate\Foundation\Testing\RefreshDatabase;
6+
use Illuminate\Support\Facades\Queue;
7+
use Laravel\Socialite\Contracts\Provider;
8+
use Laravel\Socialite\Facades\Socialite;
9+
use Laravel\Socialite\Two\User as SocialiteUser;
10+
use Tests\TestCase;
11+
12+
uses(TestCase::class);
13+
uses(RefreshDatabase::class);
14+
15+
test('users can start connecting their GitHub account from settings', function () {
16+
$user = $this->login();
17+
18+
$response = $this->actingAs($user)->post('/settings/github/connect');
19+
20+
$response->assertRedirect(route('login.github'));
21+
22+
expect(session('settings.github.connect.intended'))->toBeTrue();
23+
});
24+
25+
test('users can disconnect their GitHub account from settings', function () {
26+
$user = $this->login([
27+
'github_id' => '11405387',
28+
'github_username' => 'theHocineSaad',
29+
'github_has_identicon' => true,
30+
]);
31+
32+
$response = $this->actingAs($user)->post('/settings/github/disconnect');
33+
34+
$response->assertRedirect(route('settings.profile'));
35+
$response->assertSessionHas('success', 'Your GitHub account has been disconnected.');
36+
37+
$user->refresh();
38+
39+
expect($user->github_id)->toBeNull();
40+
expect($user->github_username)->toBeNull();
41+
expect($user->github_has_identicon)->toBeFalse();
42+
});
43+
44+
test('users can connect their GitHub account after returning from GitHub', function () {
45+
Queue::fake();
46+
47+
$user = $this->login([
48+
'github_id' => null,
49+
'github_username' => null,
50+
]);
51+
52+
$socialiteUser = fakeSocialiteUser('11405387', 'theHocineSaad');
53+
54+
mockGitHubProvider($socialiteUser);
55+
56+
$this->withSession(['settings.github.connect.intended' => true]);
57+
58+
$response = $this->actingAs($user)->get('/auth/github');
59+
60+
$response->assertRedirect(route('settings.profile'));
61+
$response->assertSessionHas('success', 'Your GitHub account has been connected.');
62+
63+
$user->refresh();
64+
65+
expect($user->github_id)->toBe('11405387');
66+
expect($user->github_username)->toBe('theHocineSaad');
67+
68+
Queue::assertPushed(UpdateUserIdenticonStatus::class);
69+
});
70+
71+
test('users cannot connect a GitHub account that belongs to another user', function () {
72+
Queue::fake();
73+
74+
User::factory()->create([
75+
'github_id' => '11405387',
76+
'github_username' => 'theHocineSaad',
77+
]);
78+
79+
$user = $this->login([
80+
'github_id' => null,
81+
'github_username' => null,
82+
]);
83+
84+
$socialiteUser = fakeSocialiteUser('11405387', 'theHocineSaad');
85+
86+
mockGitHubProvider($socialiteUser);
87+
88+
$this->withSession(['settings.github.connect.intended' => true]);
89+
90+
$response = $this->actingAs($user)->get('/auth/github');
91+
92+
$response->assertRedirect(route('settings.profile'));
93+
$response->assertSessionHas('error', 'This GitHub account is already connected to another user.');
94+
95+
$user->refresh();
96+
97+
expect($user->github_id)->toBeNull();
98+
expect($user->github_username)->toBeNull();
99+
100+
Queue::assertNothingPushed();
101+
});
102+
103+
function fakeSocialiteUser(string $id, string $nickname): SocialiteUser
104+
{
105+
return tap(new SocialiteUser())
106+
->setRaw([
107+
'id' => $id,
108+
'login' => $nickname,
109+
])
110+
->map([
111+
'id' => $id,
112+
'nickname' => $nickname,
113+
]);
114+
}
115+
116+
function mockGitHubProvider(SocialiteUser $user): void
117+
{
118+
$provider = Mockery::mock(Provider::class);
119+
$provider->shouldReceive('user')->once()->andReturn($user);
120+
121+
Socialite::shouldReceive('driver')->once()->with('github')->andReturn($provider);
122+
}

0 commit comments

Comments
 (0)