Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions app/Actions/ConnectGitHubAccount.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Actions;

use App\Jobs\UpdateUserIdenticonStatus;
use App\Models\User;
use Laravel\Socialite\Two\User as SocialiteUser;
use function dispatch;

final class ConnectGitHubAccount
{
public function __invoke(User $user, SocialiteUser $socialiteUser): void
{
$user->update([
'github_id' => $socialiteUser->getId(),
'github_username' => $socialiteUser->getNickname(),
]);

dispatch(new UpdateUserIdenticonStatus($user));
}
}
17 changes: 17 additions & 0 deletions app/Actions/DisconnectGitHubAccount.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace App\Actions;

use App\Models\User;

final class DisconnectGitHubAccount
{
public function __invoke(User $user): void
{
$user->update([
'github_id' => null,
'github_username' => null,
'github_has_identicon' => false,
]);
}
}
23 changes: 22 additions & 1 deletion app/Http/Controllers/Auth/GitHubController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -42,6 +43,26 @@ public function handleProviderCallback()
return $socialiteUser;
}

$isConnectingAttempt = session()->pull('settings.github.connect.intended', false);
if ($isConnectingAttempt) {
$currentUser = auth()->user();
$githubId = $socialiteUser->getId();

// Check if the GitHub account is already connected to another user
$existingUser = User::where('github_id', $githubId)->where('id', '!=', $currentUser->id)->first();
if ($existingUser) {
$this->error('This GitHub account is already connected to another user.');

return redirect(route('settings.profile'));
}

$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) {
Expand Down
40 changes: 40 additions & 0 deletions app/Http/Controllers/Settings/GitHubAccountController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace App\Http\Controllers\Settings;

use App\Actions\DisconnectGitHubAccount;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Middleware\Authenticate;
use Illuminate\Http\RedirectResponse;

final class GitHubAccountController extends Controller
{
public function __construct()
{
$this->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'));
}
}
5 changes: 5 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
61 changes: 61 additions & 0 deletions resources/views/users/settings/github.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
@title('GitHub')

<section aria-labelledby="github_settings_heading" class="mt-6">
<div class="shadow-sm sm:rounded-md sm:overflow-hidden">
<div class="bg-white py-6 px-4 space-y-6 sm:p-6">
<div>
<h2 id="github_settings_heading" class="text-lg leading-6 font-medium text-gray-900">
GitHub Account
</h2>

<p class="mt-1 text-sm leading-5 text-gray-500">
Connect your GitHub account to keep your profile information in sync.
</p>
</div>

@if (Auth::user()->hasConnectedGitHubAccount())
<div class="flex items-center justify-between flex-wrap gap-4">
<div class="space-y-1">
<span class="block text-sm font-medium text-gray-700">
Connected as
</span>

<a href="https://github.com/{{ Auth::user()->githubUsername() }}" class="text-lio-700 font-semibold"
target="_blank" rel="noopener">
{{ '@' . Auth::user()->githubUsername() }}
</a>
</div>

@if (Auth::user()->password)
<x-forms.form method="POST" action="{{ route('settings.github.disconnect') }}">
<x-buttons.danger-button type="submit">
Disconnect GitHub
</x-buttons.danger-button>
</x-forms.form>
@else
<p class="text-sm text-red-600 mt-2">
You must set a password before disconnecting your GitHub account, otherwise, you will not be able to log in again.
</p>
@endif
</div>
@else
<div class="flex items-center justify-between flex-wrap gap-4">
<p class="text-sm text-gray-600">
Connecting your GitHub account will automatically populate your GitHub username and use your
GitHub profile image.
</p>
</div>
@endif
</div>

@if (!Auth::user()->hasConnectedGitHubAccount())
<x-forms.form id="github_settings_form" method="POST" action="{{ route('settings.github.connect') }}">
<div class="px-4 py-3 bg-gray-50 text-right sm:px-6">
<x-buttons.primary-button type="submit">
Connect GitHub
</x-buttons.primary-button>
</div>
</x-forms.form>
@endif
</div>
</section>
9 changes: 8 additions & 1 deletion resources/views/users/settings/settings.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@
<x-heroicon-o-key class="text-gray-400 shrink-0 -ml-1 mr-3 h-6 w-6" />
<span class="truncate">Password</span>
</a>
<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">
<a href="#github_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">
<x-si-github class="text-gray-400 shrink-0 -ml-1 mr-3 h-6 w-6" />
<span class="truncate">GitHub</span>
</a>
<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">
<x-heroicon-o-code-bracket class="text-gray-400 shrink-0 -ml-1 mr-3 h-6 w-6" />
<span class="truncate">API Tokens</span>
</a>
Expand All @@ -45,6 +51,7 @@
<div class="mt-10 lg:mt-0 sm:px-6 lg:px-0 lg:col-span-3">
@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')
Expand Down
3 changes: 3 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
122 changes: 122 additions & 0 deletions tests/Feature/GitHubAccountSettingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

use App\Jobs\UpdateUserIdenticonStatus;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Laravel\Socialite\Contracts\Provider;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
use Tests\TestCase;

uses(TestCase::class);
uses(RefreshDatabase::class);

test('users can start connecting their GitHub account from settings', function () {
$user = $this->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);
}