diff --git a/app/Http/Controllers/Auth/GitHubSocialiteController.php b/app/Http/Controllers/Auth/GitHubSocialiteController.php deleted file mode 100644 index 60641504..00000000 --- a/app/Http/Controllers/Auth/GitHubSocialiteController.php +++ /dev/null @@ -1,124 +0,0 @@ -with('loginError', 'GitHub login is not enabled.'); - } - - /** @var GitHubProvider $githubProvider */ - $githubProvider = Socialite::driver('github'); - - return $githubProvider - ->scopes(['read:user']) - ->redirect(); - } - - /** - * Handle the callback from GitHub after authentication. - */ - public function handleProviderCallback(): RedirectResponse - { - try { - $githubUser = Socialite::driver('github')->user(); - - if (($user = $this->findUserByGitHubId((int) $githubUser->getId())) instanceof User) { - return $this->loginAndRedirect($user, 'Found GH ID associated with this user, logging them in.'); - } - - if (($user = $this->findUserByEmailAndUpdateGitHubId($githubUser)) instanceof User) { - return $this->loginAndRedirect($user, "Adding the user's GH ID to their account."); - } - - return $this->createUserAndLogin($githubUser); - - } catch (Exception $exception) { - Log::error('GitHub OAuth login error: ' . $exception->getMessage()); - - return Redirect::route('login')->with('error', 'Authentication failed. There may be an error with GitHub. Please try again later.'); - } - } - - /** - * Find a user by their GitHub ID. - */ - private function findUserByGitHubId(int $githubId): ?User - { - return User::where('github_id', $githubId)->first(); - } - - /** - * Find a user by email and update their GitHub ID if found. - */ - private function findUserByEmailAndUpdateGitHubId(SocialiteUser $socialiteUser): ?User - { - $user = User::where('email', $socialiteUser->getEmail())->first(); - - $user?->update(['github_id' => $socialiteUser->getId()]); - - return $user; - } - - /** - * Create a new user with GitHub data and log them in. - */ - private function createUserAndLogin(SocialiteUser $socialiteUser): RedirectResponse - { - $user = User::create([ - 'name' => $socialiteUser->getName(), - 'email' => $socialiteUser->getEmail(), - 'github_id' => $socialiteUser->getId(), - ]); - - Mail::to($user->email)->queue(new WelcomeMail($user)); - - Log::debug('Creating new user with their GitHub ID and logging them in.', ['id' => $socialiteUser->getId()]); - Auth::login($user); - - Toaster::success(__('Successfully logged in via GitHub!')); - - return Redirect::route('overview'); - } - - /** - * Log in the user and redirect to the overview page. - */ - private function loginAndRedirect(User $user, string $message): RedirectResponse - { - Log::debug($message, ['id' => $user->getAttribute('github_id')]); - Auth::login($user); - - return Redirect::route('overview'); - } -} diff --git a/app/Http/Controllers/Auth/GitLabSocialiteController.php b/app/Http/Controllers/Auth/GitLabSocialiteController.php deleted file mode 100644 index d5fc3871..00000000 --- a/app/Http/Controllers/Auth/GitLabSocialiteController.php +++ /dev/null @@ -1,96 +0,0 @@ -with('loginError', 'GitLab login is not enabled.'); - } - - return Socialite::driver('gitlab')->redirect(); - } - - /** - * Handle the callback from GitLab after authentication. - */ - public function handleProviderCallback(): RedirectResponse - { - try { - $gitlabUser = Socialite::driver('gitlab')->user(); - $localUser = $this->findUserByEmail($gitlabUser->getEmail()); - - if (! $localUser instanceof User) { - $localUser = $this->createUser($gitlabUser); - } - - Auth::login($localUser); - - Log::debug('Logging GitLab user in.', ['email' => $gitlabUser->getEmail()]); - - Toaster::success(__('Successfully logged in via GitLab!')); - - return Redirect::route('overview'); - } catch (Exception $exception) { - Log::error('GitLab OAuth login error: ' . $exception->getMessage() . ' from ' . $exception::class); - - return Redirect::route('login')->with('loginError', 'Authentication failed. There may be an error with GitLab. Please try again later.'); - } - } - - /** - * Find a user by email and return the user's object if any. - * - * @param null|string $email The email of the user - */ - private function findUserByEmail(?string $email): ?User - { - return User::where('email', $email)->first(); - } - - /** - * Create a new user with GitLab data and return the user's object. - */ - private function createUser(SocialiteUser $socialiteUser): User - { - $user = User::create([ - 'name' => $socialiteUser->getName(), - 'email' => $socialiteUser->getEmail(), - ]); - - Mail::to($user->email)->queue(new WelcomeMail($user)); - - Log::debug('Creating new user from GitLab data.', ['email' => $socialiteUser->getEmail()]); - - return $user; - } -} diff --git a/app/Http/Controllers/Connections/ConnectionsController.php b/app/Http/Controllers/Connections/ConnectionsController.php new file mode 100644 index 00000000..67fc0c34 --- /dev/null +++ b/app/Http/Controllers/Connections/ConnectionsController.php @@ -0,0 +1,144 @@ +redirect(); + } + + /** + * Handle the provider callback for authentication or account linking. + */ + protected function handleProviderCallback(string $provider): RedirectResponse + { + $socialiteUser = Socialite::driver($provider)->user(); + + $existingConnection = UserConnection::where('provider_name', $provider) + ->where('provider_user_id', $socialiteUser->getId()) + ->first(); + + if ($existingConnection) { + return $this->signInWithExistingConnection($existingConnection); + } + + $user = Auth::user(); + if ($user instanceof User) { + return $this->linkAccount($user, $provider, $socialiteUser); + } + + return $this->registerOrSignInUser($provider, $socialiteUser); + } + + /** + * Sign in with an existing connection. + */ + protected function signInWithExistingConnection(UserConnection $userConnection): RedirectResponse + { + $user = $userConnection->getAttribute('user'); + if ($user instanceof User) { + Auth::login($user); + + return redirect()->route('overview'); + } + + Toaster::error('Unable to sign in. User not found.'); + + return redirect()->route('login'); + } + + /** + * Register a new user or sign in an existing user based on their email. + */ + protected function registerOrSignInUser(string $provider, SocialiteUser $socialiteUser): RedirectResponse + { + $user = User::where('email', $socialiteUser->getEmail())->first(); + + if ($user instanceof User) { + $this->createConnection($user, $provider, $socialiteUser); + Auth::login($user); + Toaster::success(ucfirst($provider) . ' account successfully linked and authenticated.'); + + return redirect()->route('overview'); + } + + $user = User::create([ + 'name' => $socialiteUser->getName(), + 'email' => $socialiteUser->getEmail(), + ]); + + $this->createConnection($user, $provider, $socialiteUser); + Auth::login($user); + + Toaster::success('Account created and authenticated via ' . ucfirst($provider) . '.'); + + return redirect()->route('overview'); + } + + /** + * Link a provider account to an existing user. + */ + protected function linkAccount(User $user, string $provider, SocialiteUser $socialiteUser): RedirectResponse + { + $this->createConnection($user, $provider, $socialiteUser); + + Toaster::success(ucfirst($provider) . ' account successfully linked to your profile.'); + + return redirect()->route('profile.connections'); + } + + /** + * Create a new user connection. + */ + protected function createConnection(User $user, string $provider, SocialiteUser $socialiteUser): void + { + $expiresIn = method_exists($socialiteUser, 'getExpiresIn') ? $socialiteUser->getExpiresIn() : null; + $approvedScopes = method_exists($socialiteUser, 'getApprovedScopes') ? $socialiteUser->getApprovedScopes() : null; + + // Handle access token retrieval + $accessToken = null; + if (method_exists($socialiteUser, 'getToken')) { + $accessToken = $socialiteUser->getToken(); + } elseif (property_exists($socialiteUser, 'token')) { + $accessToken = $socialiteUser->token; + } elseif (method_exists($socialiteUser, 'accessTokenResponseBody')) { + $tokenResponse = $socialiteUser->accessTokenResponseBody(); + $accessToken = $tokenResponse['access_token'] ?? null; + } + + // Handle refresh token retrieval + $refreshToken = null; + if (method_exists($socialiteUser, 'getRefreshToken')) { + $refreshToken = $socialiteUser->getRefreshToken(); + } elseif (property_exists($socialiteUser, 'refreshToken')) { + $refreshToken = $socialiteUser->refreshToken; + } + + $user->connections()->create([ + 'provider_name' => $provider, + 'provider_user_id' => $socialiteUser->getId(), + 'provider_email' => $socialiteUser->getEmail(), + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken, + 'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null, + 'scopes' => $approvedScopes, + ]); + } +} diff --git a/app/Http/Controllers/Connections/GitHubController.php b/app/Http/Controllers/Connections/GitHubController.php new file mode 100644 index 00000000..2e0fda97 --- /dev/null +++ b/app/Http/Controllers/Connections/GitHubController.php @@ -0,0 +1,56 @@ +redirectToProvider(UserConnection::PROVIDER_GITHUB); + } + + /** + * Handle the callback from GitHub. + */ + public function callback(): RedirectResponse + { + try { + return $this->handleProviderCallback(UserConnection::PROVIDER_GITHUB); + } catch (InvalidStateException) { + return $this->handleInvalidState(); + } catch (Exception $e) { + return $this->handleGenericError($e); + } + } + + /** + * Handle invalid state exception, which could occur if the user cancels the process. + */ + protected function handleInvalidState(): RedirectResponse + { + return redirect()->route('login') + ->with('info', 'GitHub connection was cancelled or invalid. Please try again if you want to connect your account.'); + } + + /** + * Handle generic errors during the GitHub connection process. + */ + protected function handleGenericError(Exception $exception): RedirectResponse + { + // Log the error for debugging + logger()->error('GitHub connection error', ['error' => $exception->getMessage()]); + + return redirect()->route('login') + ->with('error', 'An error occurred while connecting to GitHub. Please try again later.'); + } +} diff --git a/app/Http/Controllers/Connections/GitLabController.php b/app/Http/Controllers/Connections/GitLabController.php new file mode 100644 index 00000000..8984eb5e --- /dev/null +++ b/app/Http/Controllers/Connections/GitLabController.php @@ -0,0 +1,56 @@ +redirectToProvider(UserConnection::PROVIDER_GITLAB); + } + + /** + * Handle the callback from GitLab. + */ + public function callback(): RedirectResponse + { + try { + return $this->handleProviderCallback(UserConnection::PROVIDER_GITLAB); + } catch (InvalidStateException) { + return $this->handleInvalidState(); + } catch (Exception $e) { + return $this->handleGenericError($e); + } + } + + /** + * Handle invalid state exception, which could occur if the user cancels the process. + */ + protected function handleInvalidState(): RedirectResponse + { + return redirect()->route('login') + ->with('info', 'GitLab connection was cancelled or invalid. Please try again if you want to connect your account.'); + } + + /** + * Handle generic errors during the GitLab connection process. + */ + protected function handleGenericError(Exception $exception): RedirectResponse + { + // Log the error for debugging + logger()->error('GitLab connection error', ['error' => $exception->getMessage()]); + + return redirect()->route('login') + ->with('error', 'An error occurred while connecting to GitLab. Please try again later.'); + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index ea5071cf..0ca532ac 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -50,7 +50,6 @@ public function toArray(Request $request): array 'timezone' => $this->resource->getAttribute('timezone'), 'language' => $this->resource->getAttribute('language'), 'is_admin' => $this->resource->isAdmin(), - 'github_login_enabled' => $this->resource->canLoginWithGithub(), 'weekly_summary_enabled' => $this->resource->isOptedInForWeeklySummary(), ], 'backup_tasks' => [ diff --git a/app/Livewire/Profile/ConnectionsPage.php b/app/Livewire/Profile/ConnectionsPage.php new file mode 100644 index 00000000..956696df --- /dev/null +++ b/app/Livewire/Profile/ConnectionsPage.php @@ -0,0 +1,152 @@ + + */ + public array $activeConnections = []; + + /** + * Initialize the component state. + */ + public function mount(): void + { + $this->loadActiveConnections(); + } + + /** + * Initiates the connection process for a given provider. + */ + public function connect(string $provider): void + { + $route = match ($provider) { + 'github' => 'github.redirect', + 'gitlab' => 'gitlab.redirect', + default => null, + }; + + if ($route === null) { + Toaster::error("Unsupported provider: {$provider}"); + + return; + } + + $this->redirect(route($route)); + } + + /** + * Disconnects a service for the current user. + */ + public function disconnect(string $provider): void + { + /** @var User $user */ + $user = Auth::user(); + $deleted = $user->connections()->where('provider_name', $provider)->delete(); + + if ($deleted) { + $this->loadActiveConnections(); + Toaster::success(ucfirst($provider) . ' account unlinked successfully!'); + } else { + Toaster::error("No active connection found for {$provider}."); + } + } + + /** + * Refreshes the token for a given provider. + */ + public function refresh(string $provider): void + { + /** @var User $user */ + $user = Auth::user(); + /** @var UserConnection|null $connection */ + $connection = $user->connections()->where('provider_name', $provider)->first(); + + if (! $connection || ! $connection->getAttribute('refresh_token')) { + Toaster::error('Unable to refresh token. Please re-link your account.'); + + return; + } + + try { + $providerInstance = Socialite::driver($provider); + + if (! ($providerInstance instanceof AbstractProvider)) { + throw new RuntimeException('Provider does not support token refresh.'); + } + + $newToken = $providerInstance->refreshToken($connection->getAttribute('refresh_token')); + $connection->setAttribute('access_token', $newToken->token); + $connection->setAttribute('refresh_token', $newToken->refreshToken); + $connection->setAttribute('token_expires_at', $newToken->expiresIn ? now()->addSeconds($newToken->expiresIn)->toDateTimeString() : null); + $connection->save(); + + Toaster::success(ucfirst($provider) . ' token refreshed successfully!'); + } catch (Exception) { + Toaster::error('Failed to refresh token. Please try re-linking your account.'); + } + } + + /** + * Checks if a given provider is connected for the current user. + */ + #[Computed] + public function isConnected(string $provider): bool + { + return in_array($provider, $this->activeConnections, true); + } + + /** + * Checks if a refresh token exists for a given provider. + */ + #[Computed] + public function hasRefreshToken(string $provider): bool + { + /** @var User $user */ + $user = Auth::user(); + $connection = $user->connections()->where('provider_name', $provider)->first(); + + return $connection && ! empty($connection->getAttribute('refresh_token')); + } + + /** + * Render the component. + */ + public function render(): View + { + return view('livewire.profile.connections-page') + ->layout('components.layouts.account-app'); + } + + /** + * Load the user's active connections. + */ + private function loadActiveConnections(): void + { + /** @var User $user */ + $user = Auth::user(); + $this->activeConnections = $user->connections()->pluck('provider_name')->toArray(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 86993f60..c2b3d226 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -43,7 +43,6 @@ class User extends Authenticatable implements TwoFactorAuthenticatable 'email', 'password', 'timezone', - 'github_id', 'preferred_backup_destination_id', 'language', 'gravatar_email', @@ -170,14 +169,6 @@ public function backupTasklogCountToday(): int })->whereDate('created_at', today()->timezone($this->timezone ?? 'UTC'))->count(); } - /** - * Check if the user can log in with GitHub. - */ - public function canLoginWithGithub(): bool - { - return $this->github_id !== null; - } - /** * Determine if the user is opted in to receive weekly summaries. */ @@ -314,6 +305,18 @@ public function clearQuietMode(): void $this->forceFill(['quiet_until' => null])->save(); } + /** + * Get the user's external service connections. + * + * This relationship retrieves all connections (like GitHub, GitLab) + * associated with the user. + * + * @return HasMany */ + public function connections(): HasMany + { + return $this->hasMany(UserConnection::class); + } + /** * Get the casts array. * diff --git a/app/Models/UserConnection.php b/app/Models/UserConnection.php new file mode 100644 index 00000000..d564167b --- /dev/null +++ b/app/Models/UserConnection.php @@ -0,0 +1,78 @@ + */ + use HasFactory; + + /** + * Provider name for GitHub connections. + */ + public const string PROVIDER_GITHUB = 'github'; + + /** + * Provider name for GitLab connections. + */ + public const string PROVIDER_GITLAB = 'gitlab'; + + /** + * The attributes that aren't mass assignable. + * + * @var array + */ + protected $guarded = []; + + /** + * Get the user that owns the connection. + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Determine if the connection is for GitHub. + */ + public function isGitHub(): bool + { + return $this->provider_name === self::PROVIDER_GITHUB; + } + + /** + * Determine if the connection is for GitLab. + */ + public function isGitLab(): bool + { + return $this->provider_name === self::PROVIDER_GITLAB; + } + + /** + * The attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'token_expires_at' => 'datetime', + 'scopes' => 'json', + ]; + } +} diff --git a/database/factories/UserConnectionFactory.php b/database/factories/UserConnectionFactory.php new file mode 100644 index 00000000..42fe764d --- /dev/null +++ b/database/factories/UserConnectionFactory.php @@ -0,0 +1,58 @@ + + */ +class UserConnectionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + * + * @throws JsonException + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'provider_name' => $this->faker->randomElement([ + UserConnection::PROVIDER_GITHUB, + UserConnection::PROVIDER_GITLAB, + ]), + 'provider_user_id' => $this->faker->uuid, + 'provider_email' => $this->faker->safeEmail, + 'access_token' => $this->faker->sha256, + 'refresh_token' => $this->faker->sha256, + 'token_expires_at' => $this->faker->dateTimeBetween('now', '+1 year'), + 'scopes' => json_encode(['read:user', 'repo'], JSON_THROW_ON_ERROR), + ]; + } + + /** + * Indicate that the connection is for GitHub. + */ + public function github(): self + { + return $this->state(fn (array $attributes) => [ + 'provider_name' => UserConnection::PROVIDER_GITHUB, + ]); + } + + /** + * Indicate that the connection is for GitLab. + */ + public function gitlab(): self + { + return $this->state(fn (array $attributes) => [ + 'provider_name' => UserConnection::PROVIDER_GITLAB, + ]); + } +} diff --git a/database/migrations/2024_08_22_094419_create_user_connections_table.php b/database/migrations/2024_08_22_094419_create_user_connections_table.php new file mode 100644 index 00000000..c9626e22 --- /dev/null +++ b/database/migrations/2024_08_22_094419_create_user_connections_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); + $table->string('provider_name'); + $table->string('provider_user_id'); + $table->string('provider_email')->nullable(); + $table->text('access_token')->nullable(); + $table->text('refresh_token')->nullable(); + $table->dateTime('token_expires_at')->nullable(); + $table->json('scopes')->nullable(); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/2024_08_22_155546_transfer_github_id_to_user_connections.php b/database/migrations/2024_08_22_155546_transfer_github_id_to_user_connections.php new file mode 100644 index 00000000..bbcfeaac --- /dev/null +++ b/database/migrations/2024_08_22_155546_transfer_github_id_to_user_connections.php @@ -0,0 +1,50 @@ +get(); + + foreach ($users as $user) { + $user->connections()->create([ + 'provider_name' => 'github', + 'provider_user_id' => $user->github_id, + // Other fields are left as NULL as we don't have this information + ]); + } + + // Remove the github_id column from the users table + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('github_id'); + }); + } + + public function down(): void + { + // Add the github_id column back to the users table + Schema::table('users', function (Blueprint $table) { + $table->string('github_id')->nullable(); + }); + + // Transfer data back from user_connections to users table + $connections = DB::table('user_connections') + ->where('provider_name', 'github') + ->get(); + + foreach ($connections as $connection) { + User::where('id', $connection->user_id) + ->update(['github_id' => $connection->provider_user_id]); + } + + // Remove the github connections from user_connections table + DB::table('user_connections')->where('provider_name', 'github')->delete(); + } +}; diff --git a/resources/views/account/partials/sidebar.blade.php b/resources/views/account/partials/sidebar.blade.php index 3a2cf493..bc55c377 100644 --- a/resources/views/account/partials/sidebar.blade.php +++ b/resources/views/account/partials/sidebar.blade.php @@ -66,6 +66,14 @@ +
  • + + + @svg('heroicon-o-puzzle-piece', 'h-6 w-6 lg:h-5 lg:w-5 lg:mr-2') + {{ __('Manage Connections') }} + + +
  • diff --git a/resources/views/livewire/profile/connections-page.blade.php b/resources/views/livewire/profile/connections-page.blade.php new file mode 100644 index 00000000..05006d73 --- /dev/null +++ b/resources/views/livewire/profile/connections-page.blade.php @@ -0,0 +1,113 @@ +
    + @section('title', __('Manage Connections')) + + {{ __('Manage Connections') }} + + + + {{ __('External Service Connections') }} + + {{ __('Connect your account to external services for enhanced functionality and seamless integration.') }} + + heroicon-o-puzzle-piece + +
    + @if (config('services.github.client_id') && config('services.github.client_secret')) + +
    +
    +
    +
    +
    + GitHub +
    +
    +

    {{ __('GitHub') }}

    +

    {{ __('Connect your GitHub account for seamless integration.') }}

    +
    +
    +
    + @if ($this->isConnected('github')) + @if ($this->hasRefreshToken('github')) + + {{ __('Refresh Token') }} + + @endif + + {{ __('Disconnect') }} + + @else + + {{ __('Connect') }} + + @endif +
    +
    +
    +
    + @endif + + @if (config('services.gitlab.client_id') && config('services.gitlab.client_secret')) + +
    +
    +
    +
    +
    + GitLab +
    +
    +

    {{ __('GitLab') }}

    +

    {{ __('Link your GitLab account for extended functionality.') }}

    +
    +
    +
    + @if ($this->isConnected('gitlab')) + @if ($this->hasRefreshToken('gitlab')) + + {{ __('Refresh Token') }} + + @endif + + {{ __('Disconnect') }} + + @else + + {{ __('Connect') }} + + @endif +
    +
    +
    +
    + @endif + + + +
    +
    +
    diff --git a/routes/auth.php b/routes/auth.php index 16ab28ca..2f407622 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -1,9 +1,9 @@ name('login'); Volt::route('forgot-password', 'pages.auth.forgot-password')->name('password.request'); Volt::route('reset-password/{token}', 'pages.auth.reset-password')->name('password.reset'); - - Route::get('auth/github', [GitHubSocialiteController::class, 'redirectToProvider']) - ->name('github.redirect'); - Route::get('auth/gitlab', [GitLabSocialiteController::class, 'redirectToProvider']) - ->name('gitlab.redirect'); }); -Route::get('auth/github/callback', [GitHubSocialiteController::class, 'handleProviderCallback']) - ->name('github.callback'); +// GitHub Routes +Route::get('auth/github', [GitHubController::class, 'redirect'])->name('github.redirect'); +Route::get('auth/github/callback', [GitHubController::class, 'callback'])->name('github.callback'); -Route::get('auth/gitlab/callback', [GitLabSocialiteController::class, 'handleProviderCallback']) - ->name('gitlab.callback'); +// GitLab Routes +Route::get('auth/gitlab', [GitLabController::class, 'redirect'])->name('gitlab.redirect'); +Route::get('auth/gitlab/callback', [GitLabController::class, 'callback'])->name('gitlab.callback'); Route::middleware('auth')->group(function () { Volt::route('verify-email', 'pages.auth.verify-email')->name('verification.notice'); diff --git a/routes/breadcrumbs.php b/routes/breadcrumbs.php index 80af13c3..89b819ab 100644 --- a/routes/breadcrumbs.php +++ b/routes/breadcrumbs.php @@ -114,3 +114,7 @@ Breadcrumbs::for('profile.quiet-mode', function (BreadcrumbTrail $trail) { $trail->push(__('Manage Quiet Mode'), route('profile.quiet-mode')); }); + +Breadcrumbs::for('profile.connections', function (BreadcrumbTrail $trail) { + $trail->push(__('Manage Connections'), route('profile.connections')); +}); diff --git a/routes/web.php b/routes/web.php index 5d803959..7e8c21b4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,6 +12,7 @@ use App\Livewire\NotificationStreams\Forms\UpdateNotificationStream; use App\Livewire\NotificationStreams\Index as NotificationStreamIndex; use App\Livewire\Profile\APIPage; +use App\Livewire\Profile\ConnectionsPage; use App\Livewire\Profile\ExperimentsPage; use App\Livewire\Profile\MFAPage; use App\Livewire\Profile\QuietModePage; @@ -73,6 +74,7 @@ Route::get('profile/sessions', SessionsPage::class)->name('profile.sessions'); Route::get('profile/experiments', ExperimentsPage::class)->name('profile.experiments'); Route::get('profile/quiet-mode', QuietModePage::class)->name('profile.quiet-mode'); + Route::get('profile/connections', ConnectionsPage::class)->name('profile.connections'); }); require __DIR__ . '/auth.php'; diff --git a/tests/Feature/Auth/GithubLoginTest.php b/tests/Feature/Auth/GithubLoginTest.php deleted file mode 100644 index d07b0947..00000000 --- a/tests/Feature/Auth/GithubLoginTest.php +++ /dev/null @@ -1,83 +0,0 @@ -set('services.github.client_id', 'fake-client-id'); - config()->set('services.github.client_secret', 'fake-client-secret'); - - $response = $this->get(route('github.redirect')); - - $response->assertRedirect(); -}); - -it('redirects back to login with error if GitHub login is not enabled', function (): void { - config()->set('services.github.client_id', null); - config()->set('services.github.client_secret', null); - - $response = $this->get(route('github.redirect')); - - $response->assertRedirect(route('login')); - $response->assertSessionHas('loginError', 'GitHub login is not enabled.'); -}); - -it('logs in existing user with GitHub ID', function (): void { - $user = User::factory()->create(['github_id' => '12345']); - $mockGithubUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class); - $mockGithubUser->shouldReceive('getId')->andReturn('12345'); - $mockGithubUser->shouldReceive('getEmail')->andReturn($user->email); - - Socialite::shouldReceive('driver->user')->andReturn($mockGithubUser); - - $this->get(route('github.callback')) - ->assertRedirect(route('overview')); - - $this->assertAuthenticatedAs($user); -}); - -it('updates existing user with GitHub ID when found by email', function (): void { - $user = User::factory()->create(['email' => 'user@example.com']); - $mockGithubUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class); - $mockGithubUser->shouldReceive('getId')->andReturn('12345'); - $mockGithubUser->shouldReceive('getEmail')->andReturn('user@example.com'); - - Socialite::shouldReceive('driver->user')->andReturn($mockGithubUser); - - $this->get(route('github.callback')) - ->assertRedirect(route('overview')); - - $this->assertAuthenticatedAs($user); - $this->assertDatabaseHas('users', [ - 'email' => 'user@example.com', - 'github_id' => '12345', - ]); -}); - -it('creates a new user if none exists with GitHub ID or email', function (): void { - Toaster::fake(); - Mail::fake(); - $mockGithubUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class); - $mockGithubUser->shouldReceive('getId')->andReturn('12345'); - $mockGithubUser->shouldReceive('getEmail')->andReturn('newuser@example.com'); - $mockGithubUser->shouldReceive('getName')->andReturn('New User'); - - Socialite::shouldReceive('driver->user')->andReturn($mockGithubUser); - - $this->get(route('github.callback')) - ->assertRedirect(route('overview')); - - Toaster::assertDispatched(__('Successfully logged in via GitHub!')); - - $user = User::where('email', 'newuser@example.com')->first(); - $this->assertAuthenticatedAs($user); - Mail::assertQueued(WelcomeMail::class); - $this->assertDatabaseHas('users', [ - 'name' => 'New User', - 'email' => 'newuser@example.com', - 'github_id' => '12345', - ]); -}); diff --git a/tests/Feature/Auth/GitlabLoginTest.php b/tests/Feature/Auth/GitlabLoginTest.php deleted file mode 100644 index 9d42ad5c..00000000 --- a/tests/Feature/Auth/GitlabLoginTest.php +++ /dev/null @@ -1,87 +0,0 @@ -set('services.gitlab.client_id', 'fake-client-id'); - config()->set('services.gitlab.client_secret', 'fake-client-secret'); - - $response = $this->get(route('gitlab.redirect')); - - $response->assertRedirect(); -}); - -it('redirects back to login with error if GitLab login is not enabled', function (): void { - config()->set('services.gitlab.client_id', null); - config()->set('services.gitlab.client_secret', null); - - $response = $this->get(route('gitlab.redirect')); - - $response->assertRedirect(route('login')); - $response->assertSessionHas('loginError', 'GitLab login is not enabled.'); -}); - -it('logs in existing user', function (): void { - Toaster::fake(); - $user = User::factory()->create(); - - /** - * @var \Laravel\Socialite\Contracts\User|MockInterface - */ - $mockGitlabUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class); - $mockGitlabUser->shouldReceive('getEmail')->andReturn($user->email); - - Socialite::shouldReceive('driver->user')->andReturn($mockGitlabUser); - $this->get(route('gitlab.callback')) - ->assertRedirect(route('overview')); - - Toaster::assertDispatched(__('Successfully logged in via GitLab!')); - - $this->assertAuthenticatedAs($user); -}); - -it('creates a new user if none exists', function (): void { - Toaster::fake(); - Mail::fake(); - $this->assertDatabaseMissing('users', [ - 'name' => 'New User', - 'email' => 'newuser@example.com', - ]); - /** - * @var \Laravel\Socialite\Contracts\User|MockInterface - */ - $mockGitlabUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class); - $mockGitlabUser->shouldReceive('getEmail')->andReturn('newuser@example.com'); - $mockGitlabUser->shouldReceive('getName')->andReturn('New User'); - - Socialite::shouldReceive('driver->user')->andReturn($mockGitlabUser); - - $this->get(route('gitlab.callback')) - ->assertRedirect(route('overview')); - - Toaster::assertDispatched(__('Successfully logged in via GitLab!')); - - $user = User::where('email', 'newuser@example.com')->first(); - $this->assertAuthenticatedAs($user); - Mail::assertQueued(WelcomeMail::class); - $this->assertDatabaseHas('users', [ - 'name' => 'New User', - 'email' => 'newuser@example.com', - ]); -}); - -it('redirects back to login with error if an exception is thrown', function (): void { - Socialite::shouldReceive('driver->user')->andThrow(new InvalidStateException('Invalid State')); - - $response = $this->get(route('gitlab.callback')) - ->assertRedirect(route('login')); - $response->assertSessionHas('loginError', 'Authentication failed. There may be an error with GitLab. Please try again later.'); -}); diff --git a/tests/Feature/Connections/GitHubTest.php b/tests/Feature/Connections/GitHubTest.php new file mode 100644 index 00000000..539a3acd --- /dev/null +++ b/tests/Feature/Connections/GitHubTest.php @@ -0,0 +1,103 @@ +socialiteMock = Mockery::mock('alias:' . Socialite::class); +}); + +afterEach(function (): void { + Mockery::close(); +}); + +it('redirects to GitHub', function (): void { + $this->socialiteMock->shouldReceive('driver->redirect') + ->once() + ->andReturn(redirect('https://github.com/login')); + + $response = $this->get(route('github.redirect')); + + $response->assertRedirect('https://github.com/login'); +}); + +it('handles GitHub callback for new user', function (): void { + $mock = Mockery::mock(SocialiteUser::class); + $mock->shouldReceive([ + 'getId' => '123456', + 'getEmail' => 'test@example.com', + 'getName' => 'Test User', + 'token' => 'mock-access-token', + 'refreshToken' => 'mock-refresh-token', + ]); + + $this->socialiteMock->shouldReceive('driver->user') + ->once() + ->andReturn($mock); + + $response = $this->get(route('github.callback')); + + $response->assertRedirect(route('overview')); + $this->assertDatabaseHas('users', ['email' => 'test@example.com']); + $this->assertDatabaseHas('user_connections', [ + 'provider_name' => UserConnection::PROVIDER_GITHUB, + 'provider_user_id' => '123456', + ]); + expect(Auth::check())->toBeTrue(); +}); + +it('handles GitHub callback for existing user', function (): void { + $user = User::factory()->create(['email' => 'existing@example.com']); + + $mock = Mockery::mock(SocialiteUser::class); + $mock->shouldReceive([ + 'getId' => '789012', + 'getEmail' => 'existing@example.com', + 'getName' => 'Existing User', + 'token' => 'mock-access-token', + 'refreshToken' => 'mock-refresh-token', + ]); + + $this->socialiteMock->shouldReceive('driver->user') + ->once() + ->andReturn($mock); + + $response = $this->get(route('github.callback')); + + $response->assertRedirect(route('overview')); + $this->assertDatabaseHas('user_connections', [ + 'user_id' => $user->id, + 'provider_name' => UserConnection::PROVIDER_GITHUB, + 'provider_user_id' => '789012', + ]); + expect(Auth::id())->toBe($user->id); +}); + +it('handles GitHub callback for already linked account', function (): void { + $user = User::factory()->create(); + UserConnection::factory()->create([ + 'user_id' => $user->id, + 'provider_name' => UserConnection::PROVIDER_GITHUB, + 'provider_user_id' => '123456', + ]); + + $mock = Mockery::mock(SocialiteUser::class); + $mock->shouldReceive([ + 'getId' => '123456', + 'getEmail' => $user->email, + ]); + + $this->socialiteMock->shouldReceive('driver->user') + ->once() + ->andReturn($mock); + + $response = $this->get(route('github.callback')); + + $response->assertRedirect(route('overview')); + expect(Auth::id())->toBe($user->id); +}); diff --git a/tests/Feature/Connections/GitLabTest.php b/tests/Feature/Connections/GitLabTest.php new file mode 100644 index 00000000..5e44f8a0 --- /dev/null +++ b/tests/Feature/Connections/GitLabTest.php @@ -0,0 +1,103 @@ +socialiteMock = Mockery::mock('alias:' . Socialite::class); +}); + +afterEach(function (): void { + Mockery::close(); +}); + +it('redirects to GitLab', function (): void { + $this->socialiteMock->shouldReceive('driver->redirect') + ->once() + ->andReturn(redirect('https://gitlab.com/oauth/authorize')); + + $response = $this->get(route('gitlab.redirect')); + + $response->assertRedirect('https://gitlab.com/oauth/authorize'); +}); + +it('handles GitLab callback for new user', function (): void { + $mock = Mockery::mock(SocialiteUser::class); + $mock->shouldReceive([ + 'getId' => '123456', + 'getEmail' => 'test@example.com', + 'getName' => 'Test User', + 'token' => 'mock-access-token', + 'refreshToken' => 'mock-refresh-token', + ]); + + $this->socialiteMock->shouldReceive('driver->user') + ->once() + ->andReturn($mock); + + $response = $this->get(route('gitlab.callback')); + + $response->assertRedirect(route('overview')); + $this->assertDatabaseHas('users', ['email' => 'test@example.com']); + $this->assertDatabaseHas('user_connections', [ + 'provider_name' => UserConnection::PROVIDER_GITLAB, + 'provider_user_id' => '123456', + ]); + expect(Auth::check())->toBeTrue(); +}); + +it('handles GitLab callback for existing user', function (): void { + $user = User::factory()->create(['email' => 'existing@example.com']); + + $mock = Mockery::mock(SocialiteUser::class); + $mock->shouldReceive([ + 'getId' => '789012', + 'getEmail' => 'existing@example.com', + 'getName' => 'Existing User', + 'token' => 'mock-access-token', + 'refreshToken' => 'mock-refresh-token', + ]); + + $this->socialiteMock->shouldReceive('driver->user') + ->once() + ->andReturn($mock); + + $response = $this->get(route('gitlab.callback')); + + $response->assertRedirect(route('overview')); + $this->assertDatabaseHas('user_connections', [ + 'user_id' => $user->id, + 'provider_name' => UserConnection::PROVIDER_GITLAB, + 'provider_user_id' => '789012', + ]); + expect(Auth::id())->toBe($user->id); +}); + +it('handles GitLab callback for already linked account', function (): void { + $user = User::factory()->create(); + UserConnection::factory()->create([ + 'user_id' => $user->id, + 'provider_name' => UserConnection::PROVIDER_GITLAB, + 'provider_user_id' => '123456', + ]); + + $mock = Mockery::mock(SocialiteUser::class); + $mock->shouldReceive([ + 'getId' => '123456', + 'getEmail' => $user->email, + ]); + + $this->socialiteMock->shouldReceive('driver->user') + ->once() + ->andReturn($mock); + + $response = $this->get(route('gitlab.callback')); + + $response->assertRedirect(route('overview')); + expect(Auth::id())->toBe($user->id); +}); diff --git a/tests/Feature/Profile/Api/UserAPITest.php b/tests/Feature/Profile/Api/UserAPITest.php index a81f7a3f..0279e0d6 100644 --- a/tests/Feature/Profile/Api/UserAPITest.php +++ b/tests/Feature/Profile/Api/UserAPITest.php @@ -50,7 +50,6 @@ 'timezone', 'language', 'is_admin', - 'github_login_enabled', 'weekly_summary_enabled', ], 'backup_tasks' => [ diff --git a/tests/Feature/Profile/ConnectionsTest.php b/tests/Feature/Profile/ConnectionsTest.php new file mode 100644 index 00000000..3b5647d8 --- /dev/null +++ b/tests/Feature/Profile/ConnectionsTest.php @@ -0,0 +1,123 @@ +user = User::factory()->create(); + $this->actingAs($this->user); + Toaster::fake(); +}); + +it('can render the connections page', function (): void { + Livewire::test(ConnectionsPage::class) + ->assertViewIs('livewire.profile.connections-page') + ->assertSeeLivewire('profile.connections-page'); +}); + +it('shows GitHub connection option when configured', function (): void { + Config::set('services.github.client_id', 'fake-client-id'); + Config::set('services.github.client_secret', 'fake-client-secret'); + + Livewire::test(ConnectionsPage::class) + ->assertSee('GitHub') + ->assertSee('Connect'); +}); + +it('shows GitLab connection option when configured', function (): void { + Config::set('services.gitlab.client_id', 'fake-client-id'); + Config::set('services.gitlab.client_secret', 'fake-client-secret'); + + Livewire::test(ConnectionsPage::class) + ->assertSee('GitLab') + ->assertSee('Connect'); +}); + +it('shows connect button for non-connected services', function (): void { + Livewire::test(ConnectionsPage::class) + ->assertSee('Connect') + ->assertDontSee('Disconnect'); +}); + +it('can initiate connection process', function (): void { + $testable = Livewire::test(ConnectionsPage::class); + + $testable->call('connect', 'github') + ->assertRedirect(route('github.redirect')); +}); + +it('can disconnect a service', function (): void { + UserConnection::factory()->create([ + 'user_id' => $this->user->id, + 'provider_name' => 'github', + ]); + + Livewire::test(ConnectionsPage::class) + ->call('disconnect', 'github') + ->assertDontSee('Disconnect') + ->assertSee('Connect'); + + $this->assertDatabaseMissing('user_connections', [ + 'user_id' => $this->user->id, + 'provider_name' => 'github', + ]); + + Toaster::assertDispatched('Github account unlinked successfully!'); +}); + +it('shows error when disconnecting non-existent service', function (): void { + Livewire::test(ConnectionsPage::class) + ->call('disconnect', 'github'); + + Toaster::assertDispatched('No active connection found for github.'); +}); + +it('hides refresh token button when refresh token does not exist', function (): void { + UserConnection::factory()->create([ + 'user_id' => $this->user->id, + 'provider_name' => 'github', + 'refresh_token' => null, + ]); + + Livewire::test(ConnectionsPage::class) + ->assertDontSee('Refresh Token'); +}); + +it('handles invalid provider for connection', function (): void { + Livewire::test(ConnectionsPage::class) + ->call('connect', 'invalid-provider'); + + Toaster::assertDispatched('Unsupported provider: invalid-provider'); +}); + +it('handles invalid provider for disconnection', function (): void { + Livewire::test(ConnectionsPage::class) + ->call('disconnect', 'invalid-provider'); + + Toaster::assertDispatched('No active connection found for invalid-provider.'); +}); + +it('handles invalid provider for token refresh', function (): void { + Livewire::test(ConnectionsPage::class) + ->call('refresh', 'invalid-provider'); + + Toaster::assertDispatched('Unable to refresh token. Please re-link your account.'); +}); + +it('handles missing refresh token when refreshing', function (): void { + UserConnection::factory()->create([ + 'user_id' => $this->user->id, + 'provider_name' => 'github', + 'refresh_token' => null, + ]); + + Livewire::test(ConnectionsPage::class) + ->call('refresh', 'github'); + + Toaster::assertDispatched('Unable to refresh token. Please re-link your account.'); +}); diff --git a/tests/Unit/Models/UserConnectionTest.php b/tests/Unit/Models/UserConnectionTest.php new file mode 100644 index 00000000..c3ff230d --- /dev/null +++ b/tests/Unit/Models/UserConnectionTest.php @@ -0,0 +1,29 @@ +github()->create(); + + $this->assertTrue($userProvider->isGithub()); +}); + +test('it returns false if the provider is not github', function (): void { + $userProvider = UserConnection::factory()->gitlab()->create(); + + $this->assertTrue($userProvider->isGitLab()); +}); + +test('it returns true if the provider is gitlab', function (): void { + $userProvider = UserConnection::factory()->gitlab()->create(); + + $this->assertTrue($userProvider->isGitLab()); +}); + +test('it returns false if the provider is not gitlab', function (): void { + $userProvider = UserConnection::factory()->github()->create(); + + $this->assertFalse($userProvider->isGitLab()); +}); diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php index 5c4bda81..cfe02610 100644 --- a/tests/Unit/Models/UserTest.php +++ b/tests/Unit/Models/UserTest.php @@ -140,20 +140,6 @@ $this->assertEquals(0, $user->backupTaskLogCountToday()); }); -test('returns true if can login with github', function (): void { - - $user = User::factory()->create(['github_id' => 1]); - - $this->assertTrue($user->canLoginWithGithub()); -}); - -test('returns false if can not login with github', function (): void { - - $user = User::factory()->create(); - - $this->assertFalse($user->canLoginWithGithub()); -}); - test('returns only the users that have opted in for backup task summaries', function (): void { $userOne = User::factory()->receivesWeeklySummaries()->create(); $userTwo = User::factory()->doesNotReceiveWeeklySummaries()->create();