From c7a2847484204acfa86f42098293c910036b6a1a Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 24 Nov 2025 14:26:14 +0000 Subject: [PATCH 1/3] wip --- .../Commands/RemoveExpiredGitHubAccess.php | 50 ++++ app/Console/Kernel.php | 6 + .../GitHubIntegrationController.php | 86 ++++++ app/Models/User.php | 9 + app/Support/GitHubOAuth.php | 98 +++++++ composer.json | 1 + config/services.php | 7 + ...20818_add_github_fields_to_users_table.php | 30 +++ .../views/customer/licenses/index.blade.php | 58 ++++ routes/web.php | 8 + tests/Feature/GitHubIntegrationTest.php | 250 ++++++++++++++++++ 11 files changed, 603 insertions(+) create mode 100644 app/Console/Commands/RemoveExpiredGitHubAccess.php create mode 100644 app/Http/Controllers/GitHubIntegrationController.php create mode 100644 app/Support/GitHubOAuth.php create mode 100644 database/migrations/2025_11_20_120818_add_github_fields_to_users_table.php create mode 100644 tests/Feature/GitHubIntegrationTest.php diff --git a/app/Console/Commands/RemoveExpiredGitHubAccess.php b/app/Console/Commands/RemoveExpiredGitHubAccess.php new file mode 100644 index 00000000..afbb166b --- /dev/null +++ b/app/Console/Commands/RemoveExpiredGitHubAccess.php @@ -0,0 +1,50 @@ +whereNotNull('mobile_repo_access_granted_at') + ->whereNotNull('github_username') + ->get(); + + foreach ($users as $user) { + // Check if user still has an active Max license + if (! $user->hasActiveMaxLicense()) { + // Remove from repository + $success = $github->removeFromMobileRepo($user->github_username); + + if ($success) { + // Clear the access timestamp + $user->update([ + 'mobile_repo_access_granted_at' => null, + ]); + + $this->info("Removed access for user: {$user->email} (@{$user->github_username})"); + $removed++; + } else { + $this->error("Failed to remove access for user: {$user->email} (@{$user->github_username})"); + } + } + } + + $this->info("Total users with access removed: {$removed}"); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 682809d8..c9e58a9c 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -17,6 +17,12 @@ protected function schedule(Schedule $schedule): void ->dailyAt('09:00') ->onOneServer() ->runInBackground(); + + // Remove GitHub access for users with expired Max licenses + $schedule->command('github:remove-expired-access') + ->dailyAt('10:00') + ->onOneServer() + ->runInBackground(); } /** diff --git a/app/Http/Controllers/GitHubIntegrationController.php b/app/Http/Controllers/GitHubIntegrationController.php new file mode 100644 index 00000000..98612e4b --- /dev/null +++ b/app/Http/Controllers/GitHubIntegrationController.php @@ -0,0 +1,86 @@ +middleware('auth'); + } + + public function redirectToGitHub(): RedirectResponse + { + return Socialite::driver('github') + ->scopes(['read:user']) + ->redirect(); + } + + public function handleCallback(): RedirectResponse + { + try { + $githubUser = Socialite::driver('github')->user(); + + $user = Auth::user(); + $user->update([ + 'github_id' => $githubUser->id, + 'github_username' => $githubUser->nickname, + ]); + + return redirect()->route('customer.licenses.index') + ->with('success', 'GitHub account connected successfully!'); + } catch (\Exception $e) { + return redirect()->route('customer.licenses.index') + ->with('error', 'Failed to connect GitHub account. Please try again.'); + } + } + + public function requestRepoAccess(): RedirectResponse + { + $user = Auth::user(); + + if (! $user->github_username) { + return back()->with('error', 'Please connect your GitHub account first.'); + } + + if (! $user->hasActiveMaxLicense()) { + return back()->with('error', 'You need an active Max license to access the mobile repository.'); + } + + $github = GitHubOAuth::make(); + $success = $github->inviteToMobileRepo($user->github_username); + + if ($success) { + $user->update([ + 'mobile_repo_access_granted_at' => now(), + ]); + + return back()->with('success', 'Repository invitation sent! Please check your GitHub notifications to accept the invitation.'); + } + + return back()->with('error', 'Failed to send repository invitation. Please try again or contact support.'); + } + + public function disconnect(): RedirectResponse + { + $user = Auth::user(); + + if ($user->mobile_repo_access_granted_at && $user->github_username) { + $github = GitHubOAuth::make(); + $github->removeFromMobileRepo($user->github_username); + } + + $user->update([ + 'github_id' => null, + 'github_username' => null, + 'mobile_repo_access_granted_at' => null, + ]); + + return back()->with('success', 'GitHub account disconnected successfully.'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index e1fec6ab..f3b85145 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -55,6 +55,15 @@ public function wallOfLoveSubmissions(): HasMany return $this->hasMany(WallOfLoveSubmission::class); } + public function hasActiveMaxLicense(): bool + { + return $this->licenses() + ->where('policy_name', 'max') + ->where('is_suspended', false) + ->whereActive() + ->exists(); + } + public function getFirstNameAttribute(): ?string { if (empty($this->name)) { diff --git a/app/Support/GitHubOAuth.php b/app/Support/GitHubOAuth.php new file mode 100644 index 00000000..c4927d65 --- /dev/null +++ b/app/Support/GitHubOAuth.php @@ -0,0 +1,98 @@ +token) + ->put( + sprintf( + 'https://api.github.com/repos/%s/%s/collaborators/%s', + self::ORGANIZATION, + self::REPOSITORY, + $githubUsername + ), + [ + 'permission' => 'pull', // Read-only access + ] + ); + + if ($response->failed()) { + Log::error('Failed to invite user to GitHub repository', [ + 'username' => $githubUsername, + 'status' => $response->status(), + 'response' => $response->json(), + ]); + + return false; + } + + return true; + } + + public function removeFromMobileRepo(string $githubUsername): bool + { + $response = Http::withToken($this->token) + ->delete( + sprintf( + 'https://api.github.com/repos/%s/%s/collaborators/%s', + self::ORGANIZATION, + self::REPOSITORY, + $githubUsername + ) + ); + + if ($response->failed()) { + Log::error('Failed to remove user from GitHub repository', [ + 'username' => $githubUsername, + 'status' => $response->status(), + 'response' => $response->json(), + ]); + + return false; + } + + return true; + } + + public function checkCollaboratorStatus(string $githubUsername): ?string + { + $response = Http::withToken($this->token) + ->get( + sprintf( + 'https://api.github.com/repos/%s/%s/collaborators/%s', + self::ORGANIZATION, + self::REPOSITORY, + $githubUsername + ) + ); + + if ($response->status() === 204) { + return 'active'; + } + + if ($response->status() === 404) { + return null; + } + + return 'unknown'; + } +} diff --git a/composer.json b/composer.json index 49a0ec0c..42338ff1 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "laravel/framework": "^10.10", "laravel/pennant": "^1.18", "laravel/sanctum": "^3.3", + "laravel/socialite": "^5.23", "laravel/tinker": "^2.8", "league/commonmark": "^2.4", "livewire/livewire": "^3.6.4", diff --git a/config/services.php b/config/services.php index 238da67e..239b94e5 100644 --- a/config/services.php +++ b/config/services.php @@ -38,4 +38,11 @@ 'bifrost' => [ 'api_key' => env('BIFROST_API_KEY'), ], + + 'github' => [ + 'client_id' => env('GITHUB_CLIENT_ID'), + 'client_secret' => env('GITHUB_CLIENT_SECRET'), + 'redirect' => env('APP_URL').'/auth/github/callback', + 'token' => env('GITHUB_TOKEN'), // For API calls (admin:org scope required) + ], ]; diff --git a/database/migrations/2025_11_20_120818_add_github_fields_to_users_table.php b/database/migrations/2025_11_20_120818_add_github_fields_to_users_table.php new file mode 100644 index 00000000..a7efdf7a --- /dev/null +++ b/database/migrations/2025_11_20_120818_add_github_fields_to_users_table.php @@ -0,0 +1,30 @@ +string('github_id')->nullable()->after('email'); + $table->string('github_username')->nullable()->after('github_id'); + $table->timestamp('mobile_repo_access_granted_at')->nullable()->after('github_username'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['github_id', 'github_username', 'mobile_repo_access_granted_at']); + }); + } +}; diff --git a/resources/views/customer/licenses/index.blade.php b/resources/views/customer/licenses/index.blade.php index c7fc229d..a9fa1b59 100644 --- a/resources/views/customer/licenses/index.blade.php +++ b/resources/views/customer/licenses/index.blade.php @@ -26,6 +26,64 @@ {{-- Content --}}
+ {{-- GitHub Integration Card --}} + @if(auth()->user()->hasActiveMaxLicense()) +
+
+
+
+

+ GitHub Repository Access +

+
+ @if(auth()->user()->github_username) +

Connected as {{ '@' . auth()->user()->github_username }}

+ @if(auth()->user()->mobile_repo_access_granted_at) +

+ + Access Granted + + {{ auth()->user()->mobile_repo_access_granted_at->diffForHumans() }} +

+ @else +

Your Max license grants you access to the nativephp/mobile repository.

+ @endif + @else +

Connect your GitHub account to get access to the nativephp/mobile repository with your Max license.

+ @endif +
+
+
+ @if(auth()->user()->github_username) + @if(!auth()->user()->mobile_repo_access_granted_at) +
+ @csrf + +
+ @endif +
+ @csrf + @method('DELETE') + +
+ @else + + + Connect GitHub + + @endif +
+
+
+
+ @endif + @if($licenses->count() > 0)
    diff --git a/routes/web.php b/routes/web.php index 8e4c6d86..14abc8c6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -111,6 +111,14 @@ ->middleware(EnsureFeaturesAreActive::using(ShowAuthButtons::class)) ->name('customer.logout'); +// GitHub OAuth routes +Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class)])->group(function () { + Route::get('auth/github', [App\Http\Controllers\GitHubIntegrationController::class, 'redirectToGitHub'])->name('github.redirect'); + Route::get('auth/github/callback', [App\Http\Controllers\GitHubIntegrationController::class, 'handleCallback'])->name('github.callback'); + Route::post('customer/github/request-access', [App\Http\Controllers\GitHubIntegrationController::class, 'requestRepoAccess'])->name('github.request-access'); + Route::delete('customer/github/disconnect', [App\Http\Controllers\GitHubIntegrationController::class, 'disconnect'])->name('github.disconnect'); +}); + // Customer license management routes Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class)])->prefix('customer')->name('customer.')->group(function () { Route::get('licenses', [CustomerLicenseController::class, 'index'])->name('licenses'); diff --git a/tests/Feature/GitHubIntegrationTest.php b/tests/Feature/GitHubIntegrationTest.php new file mode 100644 index 00000000..f277262f --- /dev/null +++ b/tests/Feature/GitHubIntegrationTest.php @@ -0,0 +1,250 @@ +create(); + License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'max', + 'expires_at' => now()->addDays(30), + 'is_suspended' => false, + ]); + + $response = $this->actingAs($user)->get('/customer/licenses'); + + $response->assertStatus(200); + $response->assertSee('GitHub Repository Access'); + $response->assertSee('Connect GitHub'); + } + + public function test_user_without_max_license_does_not_see_github_integration_card(): void + { + $user = User::factory()->create(); + License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'pro', + 'expires_at' => now()->addDays(30), + 'is_suspended' => false, + ]); + + $response = $this->actingAs($user)->get('/customer/licenses'); + + $response->assertStatus(200); + $response->assertDontSee('GitHub Repository Access'); + } + + public function test_user_with_connected_github_sees_username(): void + { + $user = User::factory()->create([ + 'github_username' => 'testuser', + 'github_id' => '123456', + ]); + License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'max', + 'expires_at' => now()->addDays(30), + 'is_suspended' => false, + ]); + + $response = $this->actingAs($user)->get('/customer/licenses'); + + $response->assertStatus(200); + $response->assertSee('Connected as'); + $response->assertSee('@testuser'); + $response->assertSee('Request Repository Access'); + } + + public function test_user_can_request_repo_access_with_active_max_license(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile/collaborators/*' => Http::response([], 201), + ]); + + $user = User::factory()->create([ + 'github_username' => 'testuser', + 'github_id' => '123456', + ]); + License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'max', + 'expires_at' => now()->addDays(30), + 'is_suspended' => false, + ]); + + $response = $this->actingAs($user) + ->post('/customer/github/request-access'); + + $response->assertRedirect(); + $response->assertSessionHas('success'); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + ]); + + $user->refresh(); + $this->assertNotNull($user->mobile_repo_access_granted_at); + } + + public function test_user_cannot_request_repo_access_without_max_license(): void + { + $user = User::factory()->create([ + 'github_username' => 'testuser', + 'github_id' => '123456', + ]); + License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'pro', + 'expires_at' => now()->addDays(30), + 'is_suspended' => false, + ]); + + $response = $this->actingAs($user) + ->post('/customer/github/request-access'); + + $response->assertRedirect(); + $response->assertSessionHas('error'); + } + + public function test_user_cannot_request_repo_access_without_connected_github(): void + { + $user = User::factory()->create(); + License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'max', + 'expires_at' => now()->addDays(30), + 'is_suspended' => false, + ]); + + $response = $this->actingAs($user) + ->post('/customer/github/request-access'); + + $response->assertRedirect(); + $response->assertSessionHas('error', 'Please connect your GitHub account first.'); + } + + public function test_user_can_disconnect_github_account(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile/collaborators/*' => Http::response([], 204), + ]); + + $user = User::factory()->create([ + 'github_username' => 'testuser', + 'github_id' => '123456', + 'mobile_repo_access_granted_at' => now(), + ]); + License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'max', + 'expires_at' => now()->addDays(30), + 'is_suspended' => false, + ]); + + $response = $this->actingAs($user) + ->delete('/customer/github/disconnect'); + + $response->assertRedirect(); + $response->assertSessionHas('success'); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'github_username' => null, + 'github_id' => null, + 'mobile_repo_access_granted_at' => null, + ]); + } + + public function test_scheduled_command_removes_access_for_expired_max_license(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile/collaborators/*' => Http::response([], 204), + ]); + + $user = User::factory()->create([ + 'github_username' => 'testuser', + 'github_id' => '123456', + 'mobile_repo_access_granted_at' => now()->subDays(10), + ]); + License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'max', + 'expires_at' => now()->subDays(1), // Expired + 'is_suspended' => false, + ]); + + $this->artisan('github:remove-expired-access') + ->assertExitCode(0); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'mobile_repo_access_granted_at' => null, + ]); + } + + public function test_scheduled_command_does_not_remove_access_for_active_max_license(): void + { + $user = User::factory()->create([ + 'github_username' => 'testuser', + 'github_id' => '123456', + 'mobile_repo_access_granted_at' => now()->subDays(10), + ]); + License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'max', + 'expires_at' => now()->addDays(30), // Active + 'is_suspended' => false, + ]); + + $this->artisan('github:remove-expired-access') + ->assertExitCode(0); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'github_username' => 'testuser', + 'mobile_repo_access_granted_at' => $user->mobile_repo_access_granted_at, + ]); + } + + public function test_user_with_granted_access_sees_access_status(): void + { + $user = User::factory()->create([ + 'github_username' => 'testuser', + 'github_id' => '123456', + 'mobile_repo_access_granted_at' => now()->subHours(2), + ]); + License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'max', + 'expires_at' => now()->addDays(30), + 'is_suspended' => false, + ]); + + $response = $this->actingAs($user)->get('/customer/licenses'); + + $response->assertStatus(200); + $response->assertSee('Access Granted'); + $response->assertSee('@testuser'); + $response->assertDontSee('Request Repository Access'); + } +} From 6393c1ea949f5e8cf3f241e2e1229289afbca394 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 19 Dec 2025 03:34:10 +0000 Subject: [PATCH 2/3] Fix --- .../GitHubIntegrationController.php | 10 +- app/Models/User.php | 1 + app/Support/GitHubOAuth.php | 28 ++ composer.json | 2 +- composer.lock | 442 +++++++++++++++++- .../components/github-access-banner.blade.php | 60 +++ .../views/customer/licenses/index.blade.php | 76 +-- tests/Feature/GitHubIntegrationTest.php | 6 +- 8 files changed, 563 insertions(+), 62 deletions(-) create mode 100644 resources/views/components/github-access-banner.blade.php diff --git a/app/Http/Controllers/GitHubIntegrationController.php b/app/Http/Controllers/GitHubIntegrationController.php index 98612e4b..5a6fd20f 100644 --- a/app/Http/Controllers/GitHubIntegrationController.php +++ b/app/Http/Controllers/GitHubIntegrationController.php @@ -5,6 +5,7 @@ use App\Support\GitHubOAuth; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; use Laravel\Socialite\Facades\Socialite; class GitHubIntegrationController extends Controller @@ -32,10 +33,15 @@ public function handleCallback(): RedirectResponse 'github_username' => $githubUser->nickname, ]); - return redirect()->route('customer.licenses.index') + return redirect()->route('customer.licenses') ->with('success', 'GitHub account connected successfully!'); } catch (\Exception $e) { - return redirect()->route('customer.licenses.index') + Log::error('GitHub OAuth callback failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return redirect()->route('customer.licenses') ->with('error', 'Failed to connect GitHub account. Please try again.'); } } diff --git a/app/Models/User.php b/app/Models/User.php index f3b85145..4a138df0 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -27,6 +27,7 @@ class User extends Authenticatable implements FilamentUser protected $casts = [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'mobile_repo_access_granted_at' => 'datetime', ]; public function canAccessPanel(Panel $panel): bool diff --git a/app/Support/GitHubOAuth.php b/app/Support/GitHubOAuth.php index c4927d65..02a91a67 100644 --- a/app/Support/GitHubOAuth.php +++ b/app/Support/GitHubOAuth.php @@ -75,6 +75,7 @@ public function removeFromMobileRepo(string $githubUsername): bool public function checkCollaboratorStatus(string $githubUsername): ?string { + // First check if they're an active collaborator $response = Http::withToken($this->token) ->get( sprintf( @@ -89,10 +90,37 @@ public function checkCollaboratorStatus(string $githubUsername): ?string return 'active'; } + // Check for pending invitation + if ($this->hasPendingInvitation($githubUsername)) { + return 'pending'; + } + if ($response->status() === 404) { return null; } return 'unknown'; } + + public function hasPendingInvitation(string $githubUsername): bool + { + $response = Http::withToken($this->token) + ->get( + sprintf( + 'https://api.github.com/repos/%s/%s/invitations', + self::ORGANIZATION, + self::REPOSITORY + ) + ); + + if ($response->failed()) { + return false; + } + + $invitations = $response->json(); + + return collect($invitations)->contains(function ($invitation) use ($githubUsername) { + return strtolower($invitation['invitee']['login'] ?? '') === strtolower($githubUsername); + }); + } } diff --git a/composer.json b/composer.json index dee70fce..d8ac9968 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ "laravel/framework": "^10.10", "laravel/pennant": "^1.18", "laravel/sanctum": "^3.3", - "laravel/socialite": "^5.23", + "laravel/socialite": "^5.24", "laravel/tinker": "^2.8", "league/commonmark": "^2.4", "livewire/livewire": "^3.6.4", diff --git a/composer.lock b/composer.lock index 7115cbf5..49389d62 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7363069a0c36eee5bb7eb259ab6134a6", + "content-hash": "565e1d22ee6d0b4e9d0611e4bfb58658", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -1746,6 +1746,69 @@ }, "time": "2025-06-12T15:11:14+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v6.11.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" + }, + "time": "2025-04-09T20:32:01+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.3.0", @@ -3114,6 +3177,78 @@ }, "time": "2024-11-14T18:34:49+00:00" }, + { + "name": "laravel/socialite", + "version": "v5.24.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/socialite.git", + "reference": "1d19358c28e8951dde6e36603b89d8f09e6cfbfd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/socialite/zipball/1d19358c28e8951dde6e36603b89d8f09e6cfbfd", + "reference": "1d19358c28e8951dde6e36603b89d8f09e6cfbfd", + "shasum": "" + }, + "require": { + "ext-json": "*", + "firebase/php-jwt": "^6.4", + "guzzlehttp/guzzle": "^6.0|^7.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "league/oauth1-client": "^1.11", + "php": "^7.2|^8.0", + "phpseclib/phpseclib": "^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8", + "phpstan/phpstan": "^1.12.23", + "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Socialite": "Laravel\\Socialite\\Facades\\Socialite" + }, + "providers": [ + "Laravel\\Socialite\\SocialiteServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Socialite\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.", + "homepage": "https://laravel.com", + "keywords": [ + "laravel", + "oauth" + ], + "support": { + "issues": "https://github.com/laravel/socialite/issues", + "source": "https://github.com/laravel/socialite" + }, + "time": "2025-12-09T15:37:06+00:00" + }, { "name": "laravel/tinker", "version": "v2.10.2", @@ -3648,6 +3783,82 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "league/oauth1-client", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth1-client.git", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^6.0|^7.0", + "guzzlehttp/psr7": "^1.7|^2.0", + "php": ">=7.1||>=8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^2.17", + "mockery/mockery": "^1.3.3", + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5||9.5" + }, + "suggest": { + "ext-simplexml": "For decoding XML-based responses." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev", + "dev-develop": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth1\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Corlett", + "email": "bencorlett@me.com", + "homepage": "http://www.webcomm.com.au", + "role": "Developer" + } + ], + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth1-client/issues", + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" + }, + "time": "2024-12-10T19:59:05+00:00" + }, { "name": "league/uri", "version": "7.6.0", @@ -4898,6 +5109,125 @@ }, "time": "2023-11-29T20:28:41+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.4", @@ -4973,6 +5303,116 @@ ], "time": "2025-08-21T11:53:16+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.48", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/64065a5679c50acb886e82c07aa139b0f757bb89", + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.48" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2025-12-15T11:51:42+00:00" + }, { "name": "psr/cache", "version": "3.0.0", diff --git a/resources/views/components/github-access-banner.blade.php b/resources/views/components/github-access-banner.blade.php new file mode 100644 index 00000000..b4cda442 --- /dev/null +++ b/resources/views/components/github-access-banner.blade.php @@ -0,0 +1,60 @@ +@props(['inline' => false]) + +@if(auth()->user()->hasActiveMaxLicense()) +
    !$inline])> +
    +
    +
    + +
    +
    +

    + GitHub Repository Access +

    +
    + @if(auth()->user()->github_username) +

    Connected as {{ '@' . auth()->user()->github_username }}

    + @if(auth()->user()->mobile_repo_access_granted_at) +

    + + Invitation Sent + +

    +

    Check your GitHub notifications to accept.

    + @else +

    Request access to the nativephp/mobile repository.

    + @endif + @else +

    Connect your GitHub account to access the nativephp/mobile repository.

    + @endif +
    +
    + @if(auth()->user()->github_username) + @if(!auth()->user()->mobile_repo_access_granted_at) +
    + @csrf + +
    + @endif +
    + @csrf + @method('DELETE') + +
    + @else + + Connect GitHub + + @endif +
    +
    +
    +
    +
    +@endif diff --git a/resources/views/customer/licenses/index.blade.php b/resources/views/customer/licenses/index.blade.php index be66880f..7c4f826b 100644 --- a/resources/views/customer/licenses/index.blade.php +++ b/resources/views/customer/licenses/index.blade.php @@ -24,68 +24,34 @@ {{-- Banners --}}
    -
    +
    +
    {{-- Content --}}
    - {{-- GitHub Integration Card --}} - @if(auth()->user()->hasActiveMaxLicense()) -
    -
    -
    -
    -

    - GitHub Repository Access -

    -
    - @if(auth()->user()->github_username) -

    Connected as {{ '@' . auth()->user()->github_username }}

    - @if(auth()->user()->mobile_repo_access_granted_at) -

    - - Access Granted - - {{ auth()->user()->mobile_repo_access_granted_at->diffForHumans() }} -

    - @else -

    Your Max license grants you access to the nativephp/mobile repository.

    - @endif - @else -

    Connect your GitHub account to get access to the nativephp/mobile repository with your Max license.

    - @endif -
    -
    -
    - @if(auth()->user()->github_username) - @if(!auth()->user()->mobile_repo_access_granted_at) -
    - @csrf - -
    - @endif -
    - @csrf - @method('DELETE') - -
    - @else - - - Connect GitHub - - @endif -
    -
    + {{-- Flash Messages --}} + @if(session()->has('success')) +
    +
    + + + +

    {{ session('success') }}

    +
    +
    + @endif + + @if(session()->has('error')) +
    +
    + + + +

    {{ session('error') }}

    @endif diff --git a/tests/Feature/GitHubIntegrationTest.php b/tests/Feature/GitHubIntegrationTest.php index f277262f..7ff7b767 100644 --- a/tests/Feature/GitHubIntegrationTest.php +++ b/tests/Feature/GitHubIntegrationTest.php @@ -72,7 +72,7 @@ public function test_user_with_connected_github_sees_username(): void $response->assertStatus(200); $response->assertSee('Connected as'); $response->assertSee('@testuser'); - $response->assertSee('Request Repository Access'); + $response->assertSee('Request Access'); } public function test_user_can_request_repo_access_with_active_max_license(): void @@ -243,8 +243,8 @@ public function test_user_with_granted_access_sees_access_status(): void $response = $this->actingAs($user)->get('/customer/licenses'); $response->assertStatus(200); - $response->assertSee('Access Granted'); + $response->assertSee('Invitation Sent'); $response->assertSee('@testuser'); - $response->assertDontSee('Request Repository Access'); + $response->assertDontSee('Request Access'); } } From 2b97e3146bed44a199ffb84244ffdd0acba36dd6 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 21 Dec 2025 15:15:13 +0000 Subject: [PATCH 3/3] Detect current status --- app/Livewire/GitHubAccessBanner.php | 61 +++++++++++++++++++ .../views/customer/licenses/index.blade.php | 2 +- .../git-hub-access-banner.blade.php} | 31 +++++++--- tests/Feature/GitHubIntegrationTest.php | 59 +++++++++++++++--- 4 files changed, 137 insertions(+), 16 deletions(-) create mode 100644 app/Livewire/GitHubAccessBanner.php rename resources/views/{components/github-access-banner.blade.php => livewire/git-hub-access-banner.blade.php} (68%) diff --git a/app/Livewire/GitHubAccessBanner.php b/app/Livewire/GitHubAccessBanner.php new file mode 100644 index 00000000..dda30a32 --- /dev/null +++ b/app/Livewire/GitHubAccessBanner.php @@ -0,0 +1,61 @@ +inline = $inline; + $this->checkCollaboratorStatus(); + } + + public function checkCollaboratorStatus(): void + { + $user = auth()->user(); + + if (! $user || ! $user->github_username) { + $this->collaboratorStatus = null; + + return; + } + + // Cache the status for 5 minutes to avoid excessive API calls + $cacheKey = "github_collab_status_{$user->id}"; + + $this->collaboratorStatus = Cache::remember($cacheKey, 300, function () use ($user) { + $github = GitHubOAuth::make(); + + return $github->checkCollaboratorStatus($user->github_username); + }); + + // If they have active access but we haven't recorded it, update our record + if ($this->collaboratorStatus === 'active' && ! $user->mobile_repo_access_granted_at) { + $user->update(['mobile_repo_access_granted_at' => now()]); + } + } + + public function refreshStatus(): void + { + $user = auth()->user(); + + if ($user) { + Cache::forget("github_collab_status_{$user->id}"); + } + + $this->checkCollaboratorStatus(); + } + + public function render() + { + return view('livewire.git-hub-access-banner'); + } +} diff --git a/resources/views/customer/licenses/index.blade.php b/resources/views/customer/licenses/index.blade.php index 7c4f826b..2e81459e 100644 --- a/resources/views/customer/licenses/index.blade.php +++ b/resources/views/customer/licenses/index.blade.php @@ -27,7 +27,7 @@
    - +
    diff --git a/resources/views/components/github-access-banner.blade.php b/resources/views/livewire/git-hub-access-banner.blade.php similarity index 68% rename from resources/views/components/github-access-banner.blade.php rename to resources/views/livewire/git-hub-access-banner.blade.php index b4cda442..e287fbe7 100644 --- a/resources/views/components/github-access-banner.blade.php +++ b/resources/views/livewire/git-hub-access-banner.blade.php @@ -1,5 +1,4 @@ -@props(['inline' => false]) - +
    @if(auth()->user()->hasActiveMaxLicense())
    !$inline])>
    @@ -11,18 +10,26 @@

    - GitHub Repository Access + nativephp/mobile Repo Access

    @if(auth()->user()->github_username)

    Connected as {{ '@' . auth()->user()->github_username }}

    - @if(auth()->user()->mobile_repo_access_granted_at) + + @if($collaboratorStatus === 'active')

    - Invitation Sent + Access Granted

    -

    Check your GitHub notifications to accept.

    +

    You have access to the nativephp/mobile repository.

    + @elseif($collaboratorStatus === 'pending') +

    + + Invitation Pending + +

    +

    Check your GitHub notifications to accept the invitation.

    @else

    Request access to the nativephp/mobile repository.

    @endif @@ -32,7 +39,16 @@
    @if(auth()->user()->github_username) - @if(!auth()->user()->mobile_repo_access_granted_at) + @if($collaboratorStatus === 'active') + + View Repo + + @elseif($collaboratorStatus === 'pending') + + @else
    @csrf
    @endif +
    diff --git a/tests/Feature/GitHubIntegrationTest.php b/tests/Feature/GitHubIntegrationTest.php index 7ff7b767..92434e17 100644 --- a/tests/Feature/GitHubIntegrationTest.php +++ b/tests/Feature/GitHubIntegrationTest.php @@ -6,6 +6,7 @@ use App\Models\License; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Laravel\Pennant\Feature; use Tests\TestCase; @@ -19,10 +20,15 @@ protected function setUp(): void parent::setUp(); Feature::define(ShowAuthButtons::class, true); + + // Clear cache between tests to avoid stale collaborator status + Cache::flush(); } public function test_user_with_active_max_license_sees_github_integration_card(): void { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + $user = User::factory()->create(); License::factory()->create([ 'user_id' => $user->id, @@ -34,12 +40,15 @@ public function test_user_with_active_max_license_sees_github_integration_card() $response = $this->actingAs($user)->get('/customer/licenses'); $response->assertStatus(200); - $response->assertSee('GitHub Repository Access'); + $response->assertSee('nativephp/mobile'); + $response->assertSee('Repo Access'); $response->assertSee('Connect GitHub'); } public function test_user_without_max_license_does_not_see_github_integration_card(): void { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + $user = User::factory()->create(); License::factory()->create([ 'user_id' => $user->id, @@ -51,11 +60,13 @@ public function test_user_without_max_license_does_not_see_github_integration_ca $response = $this->actingAs($user)->get('/customer/licenses'); $response->assertStatus(200); - $response->assertDontSee('GitHub Repository Access'); + $response->assertDontSee('Repo Access'); } public function test_user_with_connected_github_sees_username(): void { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + $user = User::factory()->create([ 'github_username' => 'testuser', 'github_id' => '123456', @@ -78,7 +89,7 @@ public function test_user_with_connected_github_sees_username(): void public function test_user_can_request_repo_access_with_active_max_license(): void { Http::fake([ - 'api.github.com/repos/nativephp/mobile/collaborators/*' => Http::response([], 201), + 'api.github.com/repos/nativephp/mobile/collaborators/testuser' => Http::response([], 201), ]); $user = User::factory()->create([ @@ -146,7 +157,7 @@ public function test_user_cannot_request_repo_access_without_connected_github(): public function test_user_can_disconnect_github_account(): void { Http::fake([ - 'api.github.com/repos/nativephp/mobile/collaborators/*' => Http::response([], 204), + 'api.github.com/repos/nativephp/mobile/collaborators/testuser' => Http::response([], 204), ]); $user = User::factory()->create([ @@ -178,7 +189,7 @@ public function test_user_can_disconnect_github_account(): void public function test_scheduled_command_removes_access_for_expired_max_license(): void { Http::fake([ - 'api.github.com/repos/nativephp/mobile/collaborators/*' => Http::response([], 204), + 'api.github.com/repos/nativephp/mobile/collaborators/testuser' => Http::response([], 204), ]); $user = User::factory()->create([ @@ -226,12 +237,15 @@ public function test_scheduled_command_does_not_remove_access_for_active_max_lic ]); } - public function test_user_with_granted_access_sees_access_status(): void + public function test_user_with_active_collaborator_status_sees_access_granted(): void { + Http::fake([ + 'api.github.com/repos/nativephp/mobile/collaborators/testuser' => Http::response([], 204), + ]); + $user = User::factory()->create([ 'github_username' => 'testuser', 'github_id' => '123456', - 'mobile_repo_access_granted_at' => now()->subHours(2), ]); License::factory()->create([ 'user_id' => $user->id, @@ -243,8 +257,37 @@ public function test_user_with_granted_access_sees_access_status(): void $response = $this->actingAs($user)->get('/customer/licenses'); $response->assertStatus(200); - $response->assertSee('Invitation Sent'); + $response->assertSee('Access Granted'); $response->assertSee('@testuser'); + $response->assertSee('View Repo'); $response->assertDontSee('Request Access'); } + + public function test_user_with_pending_invitation_sees_pending_status(): void + { + Http::fake([ + 'api.github.com/repos/nativephp/mobile/collaborators/testuser' => Http::response([], 404), + 'api.github.com/repos/nativephp/mobile/invitations' => Http::response([ + ['invitee' => ['login' => 'testuser']], + ], 200), + ]); + + $user = User::factory()->create([ + 'github_username' => 'testuser', + 'github_id' => '123456', + ]); + License::factory()->create([ + 'user_id' => $user->id, + 'policy_name' => 'max', + 'expires_at' => now()->addDays(30), + 'is_suspended' => false, + ]); + + $response = $this->actingAs($user)->get('/customer/licenses'); + + $response->assertStatus(200); + $response->assertSee('Invitation Pending'); + $response->assertSee('@testuser'); + $response->assertSee('Check Status'); + } }