From c7879da390f402e20c3f9c47643e7d5b8e606be3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:45:19 +0100 Subject: [PATCH] feat(github): split GitHub app source page into tabbed views Refactor the GitHub source UI into dedicated General, Permissions & Events, and Resources Livewire pages/components with route-based navigation. Also keeps permission/event refetch and resource listing behavior intact while improving page organization and adds feature coverage for the new pages. --- .../Source/Github/PermissionsEvents.php | 25 ++ app/Livewire/Source/Github/Resources.php | 25 ++ app/Livewire/Source/Github/Tabs/General.php | 248 +++++++++++++++++ .../Source/Github/Tabs/PermissionsEvents.php | 105 ++++++++ app/Livewire/Source/Github/Tabs/Resources.php | 25 ++ .../livewire/source/github/change.blade.php | 249 ++---------------- .../github/permissions-events.blade.php | 28 ++ .../source/github/resources.blade.php | 28 ++ .../source/github/tabs/general.blade.php | 118 +++++++++ .../github/tabs/permissions-events.blade.php | 55 ++++ .../source/github/tabs/resources.blade.php | 62 +++++ routes/web.php | 4 + tests/Feature/GithubSourcePagesTest.php | 56 ++++ 13 files changed, 803 insertions(+), 225 deletions(-) create mode 100644 app/Livewire/Source/Github/PermissionsEvents.php create mode 100644 app/Livewire/Source/Github/Resources.php create mode 100644 app/Livewire/Source/Github/Tabs/General.php create mode 100644 app/Livewire/Source/Github/Tabs/PermissionsEvents.php create mode 100644 app/Livewire/Source/Github/Tabs/Resources.php create mode 100644 resources/views/livewire/source/github/permissions-events.blade.php create mode 100644 resources/views/livewire/source/github/resources.blade.php create mode 100644 resources/views/livewire/source/github/tabs/general.blade.php create mode 100644 resources/views/livewire/source/github/tabs/permissions-events.blade.php create mode 100644 resources/views/livewire/source/github/tabs/resources.blade.php create mode 100644 tests/Feature/GithubSourcePagesTest.php diff --git a/app/Livewire/Source/Github/PermissionsEvents.php b/app/Livewire/Source/Github/PermissionsEvents.php new file mode 100644 index 000000000..968653043 --- /dev/null +++ b/app/Livewire/Source/Github/PermissionsEvents.php @@ -0,0 +1,25 @@ +github_app = GithubApp::ownedByCurrentTeam()->whereUuid($github_app_uuid)->firstOrFail(); + + if (! data_get($this->github_app, 'app_id')) { + $this->redirectRoute('source.github.show', ['github_app_uuid' => $this->github_app->uuid], navigate: true); + } + } + + public function render() + { + return view('livewire.source.github.permissions-events'); + } +} diff --git a/app/Livewire/Source/Github/Resources.php b/app/Livewire/Source/Github/Resources.php new file mode 100644 index 000000000..27300b622 --- /dev/null +++ b/app/Livewire/Source/Github/Resources.php @@ -0,0 +1,25 @@ +github_app = GithubApp::ownedByCurrentTeam()->whereUuid($github_app_uuid)->firstOrFail(); + + if (! data_get($this->github_app, 'app_id')) { + $this->redirectRoute('source.github.show', ['github_app_uuid' => $this->github_app->uuid], navigate: true); + } + } + + public function render() + { + return view('livewire.source.github.resources'); + } +} diff --git a/app/Livewire/Source/Github/Tabs/General.php b/app/Livewire/Source/Github/Tabs/General.php new file mode 100644 index 000000000..8c08e5444 --- /dev/null +++ b/app/Livewire/Source/Github/Tabs/General.php @@ -0,0 +1,248 @@ + 'required|string', + 'organization' => 'nullable|string', + 'apiUrl' => 'required|string', + 'htmlUrl' => 'required|string', + 'customUser' => 'required|string', + 'customPort' => 'required|int', + 'appId' => 'nullable|int', + 'installationId' => 'nullable|int', + 'clientId' => 'nullable|string', + 'clientSecret' => 'nullable|string', + 'webhookSecret' => 'nullable|string', + 'isSystemWide' => 'required|bool', + 'privateKeyId' => 'nullable|int', + ]; + + public function mount(string $githubAppUuid): void + { + $this->github_app = GithubApp::ownedByCurrentTeam()->whereUuid($githubAppUuid)->firstOrFail(); + $this->github_app->makeVisible(['client_secret', 'webhook_secret']); + $this->applications = $this->github_app->applications; + $this->privateKeys = PrivateKey::ownedByCurrentTeamCached(); + + $this->syncData(); + $this->name = str($this->github_app->name)->kebab(); + } + + private function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->github_app->name = $this->name; + $this->github_app->organization = $this->organization; + $this->github_app->api_url = $this->apiUrl; + $this->github_app->html_url = $this->htmlUrl; + $this->github_app->custom_user = $this->customUser; + $this->github_app->custom_port = $this->customPort; + $this->github_app->app_id = $this->appId; + $this->github_app->installation_id = $this->installationId; + $this->github_app->client_id = $this->clientId; + $this->github_app->client_secret = $this->clientSecret; + $this->github_app->webhook_secret = $this->webhookSecret; + $this->github_app->is_system_wide = $this->isSystemWide; + $this->github_app->private_key_id = $this->privateKeyId; + + return; + } + + $this->name = $this->github_app->name; + $this->organization = $this->github_app->organization; + $this->apiUrl = $this->github_app->api_url; + $this->htmlUrl = $this->github_app->html_url; + $this->customUser = $this->github_app->custom_user; + $this->customPort = $this->github_app->custom_port; + $this->appId = $this->github_app->app_id; + $this->installationId = $this->github_app->installation_id; + $this->clientId = $this->github_app->client_id; + $this->clientSecret = $this->github_app->client_secret; + $this->webhookSecret = $this->github_app->webhook_secret; + $this->isSystemWide = $this->github_app->is_system_wide; + $this->privateKeyId = $this->github_app->private_key_id; + } + + public function getGithubAppNameUpdatePath(): string + { + if (str($this->github_app->organization)->isNotEmpty()) { + return "{$this->github_app->html_url}/organizations/{$this->github_app->organization}/settings/apps/{$this->github_app->name}"; + } + + return "{$this->github_app->html_url}/settings/apps/{$this->github_app->name}"; + } + + private function generateGithubJwt(string $privateKey, int $appId): string + { + $configuration = Configuration::forAsymmetricSigner( + new Sha256, + InMemory::plainText($privateKey), + InMemory::plainText($privateKey) + ); + + $now = time(); + + return $configuration->builder() + ->issuedBy((string) $appId) + ->permittedFor('https://api.github.com') + ->identifiedBy((string) $now) + ->issuedAt(new \DateTimeImmutable("@{$now}")) + ->expiresAt(new \DateTimeImmutable('@'.($now + 600))) + ->getToken($configuration->signer(), $configuration->signingKey()) + ->toString(); + } + + public function updateGithubAppName(): void + { + try { + $this->authorize('update', $this->github_app); + + $privateKey = PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id); + + if (! $privateKey) { + $this->dispatch('error', 'No private key found for this GitHub App.'); + + return; + } + + if (! $this->github_app->app_id) { + $this->dispatch('error', 'No App ID found for this GitHub App.'); + + return; + } + + $jwt = $this->generateGithubJwt($privateKey->private_key, $this->github_app->app_id); + $response = Http::withHeaders([ + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + 'Authorization' => "Bearer {$jwt}", + ])->get("{$this->github_app->api_url}/app"); + + if (! $response->successful()) { + $errorMessage = $response->json()['message'] ?? 'Unknown error'; + $this->dispatch('error', "Failed to fetch GitHub App information: {$errorMessage}"); + + return; + } + + $appData = $response->json(); + $appSlug = $appData['slug'] ?? null; + + if (! $appSlug) { + $this->dispatch('info', 'Could not find App Name (slug) in GitHub response.'); + + return; + } + + $this->github_app->name = $appSlug; + $this->name = str($appSlug)->kebab(); + $privateKey->name = "github-app-{$appSlug}"; + $privateKey->save(); + $this->github_app->save(); + $this->dispatch('success', 'GitHub App name and SSH key name synchronized successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function submit(): void + { + try { + $this->authorize('update', $this->github_app); + + $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); + $this->validate(); + $this->syncData(true); + $this->github_app->save(); + $this->dispatch('success', 'Github App updated.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function instantSave(): void + { + try { + $this->authorize('update', $this->github_app); + + $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); + $this->syncData(true); + $this->github_app->save(); + $this->dispatch('success', 'Github App updated.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function delete() + { + try { + $this->authorize('delete', $this->github_app); + + if ($this->github_app->applications->isNotEmpty()) { + $this->dispatch('error', 'This source is being used by an application. Please delete all applications first.'); + $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); + + return; + } + + $this->github_app->delete(); + + return redirect()->route('source.all'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.source.github.tabs.general'); + } +} diff --git a/app/Livewire/Source/Github/Tabs/PermissionsEvents.php b/app/Livewire/Source/Github/Tabs/PermissionsEvents.php new file mode 100644 index 000000000..0b57adc27 --- /dev/null +++ b/app/Livewire/Source/Github/Tabs/PermissionsEvents.php @@ -0,0 +1,105 @@ +github_app = GithubApp::ownedByCurrentTeam()->whereUuid($githubAppUuid)->firstOrFail(); + $this->github_app->makeVisible(['client_secret', 'webhook_secret']); + $this->syncData(); + } + + private function syncData(): void + { + $this->appId = $this->github_app->app_id; + $this->privateKeyId = $this->github_app->private_key_id; + $this->contents = $this->github_app->contents; + $this->metadata = $this->github_app->metadata; + $this->pullRequests = $this->github_app->pull_requests; + $this->organizationSelfHostedRunners = $this->github_app->organization_self_hosted_runners; + $this->webhookEvents = $this->github_app->webhook_events; + } + + public function checkPermissions(): void + { + try { + $this->authorize('view', $this->github_app); + + $missingFields = []; + + if (! $this->github_app->app_id) { + $missingFields[] = 'App ID'; + } + + if (! $this->github_app->private_key_id) { + $missingFields[] = 'Private Key'; + } + + if (! empty($missingFields)) { + $fieldsList = implode(', ', $missingFields); + $this->dispatch('error', "Cannot fetch permissions. Please set the following required fields first: {$fieldsList}"); + + return; + } + + if (! $this->github_app->privateKey) { + $this->dispatch('error', 'Private Key not found. Please select a valid private key.'); + + return; + } + + $previousEvents = $this->github_app->webhook_events ?? []; + GithubAppPermissionJob::dispatchSync($this->github_app); + $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); + $this->syncData(); + + $addedEvents = array_diff($this->github_app->webhook_events ?? [], $previousEvents); + if (! empty($addedEvents)) { + $this->dispatch('success', 'Permissions updated. Auto-enabled missing events: '.implode(', ', $addedEvents)); + + return; + } + + $this->dispatch('success', 'Github App permissions updated.'); + } catch (\Throwable $e) { + $errorMessage = $e->getMessage(); + if (str_contains($errorMessage, 'DECODER routines::unsupported') || str_contains($errorMessage, 'parse your key')) { + $this->dispatch('error', 'The selected private key format is not supported for GitHub Apps.

Please use an RSA private key in PEM format (BEGIN RSA PRIVATE KEY).

OpenSSH format keys (BEGIN OPENSSH PRIVATE KEY) are not supported.'); + + return; + } + + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.source.github.tabs.permissions-events'); + } +} diff --git a/app/Livewire/Source/Github/Tabs/Resources.php b/app/Livewire/Source/Github/Tabs/Resources.php new file mode 100644 index 000000000..cb79aaf44 --- /dev/null +++ b/app/Livewire/Source/Github/Tabs/Resources.php @@ -0,0 +1,25 @@ +github_app = GithubApp::ownedByCurrentTeam()->whereUuid($githubAppUuid)->firstOrFail(); + $this->applications = $this->github_app->applications; + } + + public function render() + { + return view('livewire.source.github.tabs.resources'); + } +} diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 81f264e31..033066c85 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -1,231 +1,30 @@
@if (data_get($github_app, 'app_id')) -
-
-

GitHub App

-
- @if (data_get($github_app, 'installation_id')) - Save - @endif - @can('delete', $github_app) - @if ($applications->count() > 0) - - @else - - @endif - @endcan -
-
-
Your Private GitHub App for private repositories.
- @if (!data_get($github_app, 'installation_id')) -
- - - - You must complete this step before you can use this source! -
- - Install Repositories on GitHub +
+

GitHub App

+
+
{{ data_get($github_app, 'name') }}
+
+ @else

GitHub App

diff --git a/resources/views/livewire/source/github/permissions-events.blade.php b/resources/views/livewire/source/github/permissions-events.blade.php new file mode 100644 index 000000000..ea444de50 --- /dev/null +++ b/resources/views/livewire/source/github/permissions-events.blade.php @@ -0,0 +1,28 @@ +
+
+

GitHub App

+
+
{{ data_get($github_app, 'name') }}
+ + + +
diff --git a/resources/views/livewire/source/github/resources.blade.php b/resources/views/livewire/source/github/resources.blade.php new file mode 100644 index 000000000..d898c9167 --- /dev/null +++ b/resources/views/livewire/source/github/resources.blade.php @@ -0,0 +1,28 @@ +
+
+

GitHub App

+
+
{{ data_get($github_app, 'name') }}
+ + + +
diff --git a/resources/views/livewire/source/github/tabs/general.blade.php b/resources/views/livewire/source/github/tabs/general.blade.php new file mode 100644 index 000000000..adf9d2ffc --- /dev/null +++ b/resources/views/livewire/source/github/tabs/general.blade.php @@ -0,0 +1,118 @@ +
+
+
+

General

+
+ @if (data_get($github_app, 'installation_id')) + Save + @endif + @can('delete', $github_app) + @if ($applications->count() > 0) + + @else + + @endif + @endcan +
+
+ + @if (!data_get($github_app, 'installation_id')) +
+ + + + You must complete this step before you can use this source! +
+ + Install Repositories on GitHub + + @else +
+
+
+ + + Sync Name + + @can('update', $github_app) + + + Rename + + + + + + Update Repositories + + + + @endcan +
+
+ + @if (!isCloud()) +
+ +
+ @if ($isSystemWide) + + System-wide GitHub Apps are shared across all teams on this Coolify instance. This means any team can use this GitHub App to deploy applications from your repositories. For better security and isolation, it's recommended to create team-specific GitHub Apps instead. + + @endif + @endif +
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + @if (blank($github_app->private_key_id)) + + @endif + @foreach ($privateKeys as $privateKey) + + @endforeach + +
+
+ @endif +
+
diff --git a/resources/views/livewire/source/github/tabs/permissions-events.blade.php b/resources/views/livewire/source/github/tabs/permissions-events.blade.php new file mode 100644 index 000000000..3f13c0696 --- /dev/null +++ b/resources/views/livewire/source/github/tabs/permissions-events.blade.php @@ -0,0 +1,55 @@ +
+
+
+

Permissions & Events

+ @if (data_get($github_app, 'installation_id')) + @can('view', $github_app) + Refetch + + + Update + + + + @endcan + @endif +
+ + @if (!data_get($github_app, 'installation_id')) +
+ Install the GitHub App first to manage permissions and webhook events. +
+ @else +
+ + + + +
+

Webhook Events

+ @if ($webhookEvents) +
+ @foreach ($webhookEvents as $event) + {{ $event }} + @endforeach +
+ @php + $missingEvents = $github_app->missingWebhookEvents(); + @endphp + @if (!empty($missingEvents)) +
+ Missing required events (will be auto-enabled on Refetch): {{ implode(', ', $missingEvents) }} +
+ @endif + @else +
+ No webhook event data yet. Click Refetch above to fetch current events. +
+ @endif + @endif +
+
diff --git a/resources/views/livewire/source/github/tabs/resources.blade.php b/resources/views/livewire/source/github/tabs/resources.blade.php new file mode 100644 index 000000000..453a66735 --- /dev/null +++ b/resources/views/livewire/source/github/tabs/resources.blade.php @@ -0,0 +1,62 @@ +
+
+
+
+

Resources

+
+
Here you can find all resources that are using this source.
+
+ + @if (!data_get($github_app, 'installation_id')) +
+ Install the GitHub App first to link resources. +
+ @elseif ($applications->isEmpty()) +
+ No resources are currently using this GitHub App. +
+ @else +
+
+
+
+
+ + + + + + + + + + + @foreach ($applications->sortBy('name', SORT_NATURAL) as $resource) + + + + + + + @endforeach + +
ProjectEnvironmentNameType
+ {{ data_get($resource->project(), 'name') }} + + {{ data_get($resource, 'environment.name') }} + + + {{ $resource->name }} + + + + {{ str($resource->type())->headline() }} +
+
+
+
+
+
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index 89425f8da..ef3879371 100644 --- a/routes/web.php +++ b/routes/web.php @@ -75,6 +75,8 @@ use App\Livewire\SharedVariables\Project\Index as ProjectSharedVariablesIndex; use App\Livewire\SharedVariables\Project\Show as ProjectSharedVariablesShow; use App\Livewire\SharedVariables\Team\Index as TeamSharedVariablesIndex; use App\Livewire\Source\Github\Change as GitHubChange; +use App\Livewire\Source\Github\PermissionsEvents as GitHubPermissionsEvents; +use App\Livewire\Source\Github\Resources as GitHubResources; use App\Livewire\Storage\Index as StorageIndex; use App\Livewire\Storage\Show as StorageShow; use App\Livewire\Subscription\Index as SubscriptionIndex; @@ -302,6 +304,8 @@ Route::middleware(['auth'])->group(function () { ]); })->name('source.all'); Route::get('/source/github/{github_app_uuid}', GitHubChange::class)->name('source.github.show'); + Route::get('/source/github/{github_app_uuid}/permissions-events', GitHubPermissionsEvents::class)->name('source.github.permissions-events'); + Route::get('/source/github/{github_app_uuid}/resources', GitHubResources::class)->name('source.github.resources'); }); Route::middleware(['auth'])->group(function () { diff --git a/tests/Feature/GithubSourcePagesTest.php b/tests/Feature/GithubSourcePagesTest.php new file mode 100644 index 000000000..f471f2b80 --- /dev/null +++ b/tests/Feature/GithubSourcePagesTest.php @@ -0,0 +1,56 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + InstanceSettings::create(['id' => 0]); +}); + +test('github source has dedicated routes for each tab page', function () { + $githubApp = GithubApp::create([ + 'name' => 'test-github-app', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'installation_id' => 67890, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + $this->get(route('source.github.show', ['github_app_uuid' => $githubApp->uuid])) + ->assertSuccessful(); + $this->get(route('source.github.permissions-events', ['github_app_uuid' => $githubApp->uuid])) + ->assertSuccessful(); + $this->get(route('source.github.resources', ['github_app_uuid' => $githubApp->uuid])) + ->assertSuccessful(); +}); + +test('permissions and resources routes redirect to general if github app is not initialized yet', function () { + $githubApp = GithubApp::create([ + 'name' => 'test-github-app', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + $this->get(route('source.github.permissions-events', ['github_app_uuid' => $githubApp->uuid])) + ->assertRedirect(route('source.github.show', ['github_app_uuid' => $githubApp->uuid])); + $this->get(route('source.github.resources', ['github_app_uuid' => $githubApp->uuid])) + ->assertRedirect(route('source.github.show', ['github_app_uuid' => $githubApp->uuid])); +});