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 @@