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