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.
This commit is contained in:
Andras Bacsai 2026-03-03 22:45:19 +01:00
parent 8f03e5058d
commit c7879da390
13 changed files with 803 additions and 225 deletions

View file

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Source\Github;
use App\Models\GithubApp;
use Livewire\Component;
class PermissionsEvents extends Component
{
public ?GithubApp $github_app = null;
public function mount(string $github_app_uuid): void
{
$this->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');
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Source\Github;
use App\Models\GithubApp;
use Livewire\Component;
class Resources extends Component
{
public ?GithubApp $github_app = null;
public function mount(string $github_app_uuid): void
{
$this->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');
}
}

View file

@ -0,0 +1,248 @@
<?php
namespace App\Livewire\Source\Github\Tabs;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Livewire\Component;
class General extends Component
{
use AuthorizesRequests;
public GithubApp $github_app;
public Collection $applications;
public Collection $privateKeys;
public string $name;
public ?string $organization = null;
public string $apiUrl;
public string $htmlUrl;
public string $customUser;
public int $customPort;
public ?int $appId = null;
public ?int $installationId = null;
public ?string $clientId = null;
public ?string $clientSecret = null;
public ?string $webhookSecret = null;
public bool $isSystemWide = false;
public ?int $privateKeyId = null;
protected $rules = [
'name' => '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');
}
}

View file

@ -0,0 +1,105 @@
<?php
namespace App\Livewire\Source\Github\Tabs;
use App\Jobs\GithubAppPermissionJob;
use App\Models\GithubApp;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class PermissionsEvents extends Component
{
use AuthorizesRequests;
public GithubApp $github_app;
public ?int $appId = null;
public ?int $privateKeyId = null;
public ?string $contents = null;
public ?string $metadata = null;
public ?string $pullRequests = null;
public ?string $organizationSelfHostedRunners = null;
public ?array $webhookEvents = null;
public function mount(string $githubAppUuid): void
{
$this->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. <br><br>Please use an RSA private key in PEM format (BEGIN RSA PRIVATE KEY). <br><br>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');
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Source\Github\Tabs;
use App\Models\GithubApp;
use Illuminate\Support\Collection;
use Livewire\Component;
class Resources extends Component
{
public GithubApp $github_app;
public Collection $applications;
public function mount(string $githubAppUuid): void
{
$this->github_app = GithubApp::ownedByCurrentTeam()->whereUuid($githubAppUuid)->firstOrFail();
$this->applications = $this->github_app->applications;
}
public function render()
{
return view('livewire.source.github.tabs.resources');
}
}

View file

@ -1,231 +1,30 @@
<div>
@if (data_get($github_app, 'app_id'))
<form wire:submit='submit'>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<h1>GitHub App</h1>
<div class="flex gap-2">
@if (data_get($github_app, 'installation_id'))
<x-forms.button canGate="update" :canResource="$github_app" type="submit">Save</x-forms.button>
@endif
@can('delete', $github_app)
@if ($applications->count() > 0)
<x-modal-confirmation title="Confirm GitHub App Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="['The selected GitHub App will be permanently deleted.']" confirmationText="{{ data_get($github_app, 'name') }}"
confirmationLabel="Please confirm the execution of the actions by entering the GitHub App Name below"
shortConfirmationLabel="GitHub App Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@else
<x-modal-confirmation title="Confirm GitHub App Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="['The selected GitHub App will be permanently deleted.']"
confirmationLabel="Please confirm the execution of the actions by entering the GitHub App Name below"
shortConfirmationLabel="GitHub App Name"
confirmationText="{{ data_get($github_app, 'name') }}" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@endif
@endcan
</div>
</div>
<div class="subtitle">Your Private GitHub App for private repositories.</div>
@if (!data_get($github_app, 'installation_id'))
<div class="mb-10 rounded-sm alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 stroke-current shrink-0" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>You must complete this step before you can use this source!</span>
</div>
<a class="items-center justify-center coolbox" href="{{ getInstallationPath($github_app) }}">
Install Repositories on GitHub
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<h1>GitHub App</h1>
</div>
<div class="subtitle ">{{ data_get($github_app, 'name') }}</div>
<div class="navbar-main mb-5">
<nav class="flex items-center gap-4 overflow-x-scroll sm:overflow-x-hidden scrollbar min-h-10 whitespace-nowrap pt-2">
<a class="{{ request()->routeIs('source.github.show') ? 'dark:text-white' : '' }}"
href="{{ route('source.github.show', ['github_app_uuid' => data_get($github_app, 'uuid')]) }}"
{{ wireNavigate() }}>
General
</a>
@else
<div class="flex flex-col gap-2">
<div class="flex flex-col sm:flex-row gap-2">
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2 w-full">
<x-forms.input canGate="update" :canResource="$github_app" id="name" label="App Name" />
<x-forms.button canGate="update" :canResource="$github_app" wire:click.prevent="updateGithubAppName">
Sync Name
</x-forms.button>
@can('update', $github_app)
<a href="{{ $this->getGithubAppNameUpdatePath() }}">
<x-forms.button
class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline">
Rename
<x-external-link />
</x-forms.button>
</a>
<a href="{{ getInstallationPath($github_app) }}" class="w-fit">
<x-forms.button
class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline whitespace-nowrap">
Update Repositories
<x-external-link />
</x-forms.button>
</a>
@endcan
</div>
</div>
<x-forms.input canGate="update" :canResource="$github_app" id="organization" label="Organization"
placeholder="If empty, personal user will be used" />
@if (!isCloud())
<div class="w-48">
<x-forms.checkbox canGate="update" :canResource="$github_app" label="System Wide?"
helper="If checked, this GitHub App will be available for everyone in this Coolify instance."
instantSave id="isSystemWide" />
</div>
@if ($isSystemWide)
<x-callout type="warning" title="Not Recommended">
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.
</x-callout>
@endif
@endif
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="htmlUrl" label="HTML Url" />
<x-forms.input canGate="update" :canResource="$github_app" id="apiUrl" label="API Url" />
</div>
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="customUser" label="User"
required />
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="customPort"
label="Port" required />
</div>
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="appId"
label="App Id" required />
<x-forms.input canGate="update" :canResource="$github_app" type="number"
id="installationId" label="Installation Id" required />
</div>
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="clientId" label="Client Id"
type="password" required />
<x-forms.input canGate="update" :canResource="$github_app" id="clientSecret"
label="Client Secret" type="password" required />
<x-forms.input canGate="update" :canResource="$github_app" id="webhookSecret"
label="Webhook Secret" type="password" required />
</div>
<div class="flex gap-2">
<x-forms.select canGate="update" :canResource="$github_app" id="privateKeyId"
label="Private Key" required>
@if (blank($github_app->private_key_id))
<option value="0" selected>Select a private key</option>
@endif
@foreach ($privateKeys as $privateKey)
<option value="{{ $privateKey->id }}">{{ $privateKey->name }}</option>
@endforeach
</x-forms.select>
</div>
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2">
<h2 class="pt-4">Permissions</h2>
@can('view', $github_app)
<x-forms.button wire:click.prevent="checkPermissions">Refetch</x-forms.button>
<a href="{{ getPermissionsPath($github_app) }}">
<x-forms.button>
Update
<x-external-link />
</x-forms.button>
</a>
@endcan
</div>
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input id="contents" helper="read - mandatory." label="Content" readonly
placeholder="N/A" />
<x-forms.input id="metadata" helper="read - mandatory." label="Metadata" readonly
placeholder="N/A" />
{{-- <x-forms.input id="administration"
helper="read:write access needed to setup servers as GitHub Runner." label="Administration"
readonly placeholder="N/A" /> --}}
<x-forms.input id="pullRequests"
helper="write access needed to use deployment status update in previews."
label="Pull Request" readonly placeholder="N/A" />
<x-forms.input id="organizationSelfHostedRunners"
helper="write access needed to use GitHub Actions self-hosted runners."
label="Runners" readonly placeholder="N/A" />
</div>
<h3 class="pt-4">Webhook Events</h3>
@if ($webhookEvents)
<div class="flex flex-wrap gap-2">
@foreach ($webhookEvents as $event)
<span class="px-2 py-1 text-xs font-mono rounded dark:bg-coolgray-200 bg-neutral-200">{{ $event }}</span>
@endforeach
</div>
@php
$missingEvents = $github_app->missingWebhookEvents();
@endphp
@if (!empty($missingEvents))
<div class="text-xs text-warning">
Missing required events (will be auto-enabled on Refetch): {{ implode(', ', $missingEvents) }}
</div>
@endif
@else
<div class="text-xs opacity-70">
No webhook event data yet. Click Refetch above to fetch current events.
</div>
@endif
</div>
@endif
</form>
@if (data_get($github_app, 'installation_id'))
<div class="w-full pt-10">
<div class="h-full">
<div class="flex flex-col">
<div class="flex gap-2">
<h2>Resources</h2>
</div>
<div class="pb-4 title">Here you can find all resources that are using this source.</div>
</div>
@if ($applications->isEmpty())
<div class="py-4 text-sm opacity-70">
No resources are currently using this GitHub App.
</div>
@else
<div class="flex flex-col">
<div class="flex flex-col">
<div class="overflow-x-auto">
<div class="inline-block min-w-full">
<div class="overflow-hidden">
<table class="min-w-full">
<thead>
<tr>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">
Project
</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">
Environment</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Name
</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Type
</th>
</tr>
</thead>
<tbody class="divide-y">
@foreach ($applications->sortBy('name',SORT_NATURAL) as $resource)
<tr>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource->project(), 'name') }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource, 'environment.name') }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap"><a
class=""
{{ wireNavigate() }}
href="{{ $resource->link() }}">{{ $resource->name }}
<x-internal-link /></a>
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ str($resource->type())->headline() }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@endif
</div>
</div>
@endif
<a class="{{ request()->routeIs('source.github.permissions-events') ? 'dark:text-white' : '' }}"
href="{{ route('source.github.permissions-events', ['github_app_uuid' => data_get($github_app, 'uuid')]) }}"
{{ wireNavigate() }}>
Permissions & Events
</a>
<a class="{{ request()->routeIs('source.github.resources') ? 'dark:text-white' : '' }}"
href="{{ route('source.github.resources', ['github_app_uuid' => data_get($github_app, 'uuid')]) }}"
{{ wireNavigate() }}>
Resources
</a>
</nav>
</div>
<livewire:source.github.tabs.general :github-app-uuid="data_get($github_app, 'uuid')"
:key="'source-github-tab-general-'.data_get($github_app, 'uuid')" />
@else
<div class="flex flex-col sm:flex-row sm:items-center gap-2 pb-4">
<h1>GitHub App</h1>

View file

@ -0,0 +1,28 @@
<div>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<h1>GitHub App</h1>
</div>
<div class="subtitle">{{ data_get($github_app, 'name') }}</div>
<div class="navbar-main mb-5">
<nav class="flex items-center gap-4 overflow-x-scroll sm:overflow-x-hidden scrollbar min-h-10 whitespace-nowrap pt-2">
<a class="{{ request()->routeIs('source.github.show') ? 'dark:text-white' : '' }}"
href="{{ route('source.github.show', ['github_app_uuid' => data_get($github_app, 'uuid')]) }}"
{{ wireNavigate() }}>
General
</a>
<a class="{{ request()->routeIs('source.github.permissions-events') ? 'dark:text-white' : '' }}"
href="{{ route('source.github.permissions-events', ['github_app_uuid' => data_get($github_app, 'uuid')]) }}"
{{ wireNavigate() }}>
Permissions & Events
</a>
<a class="{{ request()->routeIs('source.github.resources') ? 'dark:text-white' : '' }}"
href="{{ route('source.github.resources', ['github_app_uuid' => data_get($github_app, 'uuid')]) }}"
{{ wireNavigate() }}>
Resources
</a>
</nav>
</div>
<livewire:source.github.tabs.permissions-events :github-app-uuid="data_get($github_app, 'uuid')"
:key="'source-github-tab-permissions-events-'.data_get($github_app, 'uuid')" />
</div>

View file

@ -0,0 +1,28 @@
<div>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<h1>GitHub App</h1>
</div>
<div class="subtitle">{{ data_get($github_app, 'name') }}</div>
<div class="navbar-main">
<nav class="flex items-center gap-4 overflow-x-scroll sm:overflow-x-hidden scrollbar min-h-10 whitespace-nowrap pt-2">
<a class="{{ request()->routeIs('source.github.show') ? 'dark:text-white' : '' }}"
href="{{ route('source.github.show', ['github_app_uuid' => data_get($github_app, 'uuid')]) }}"
{{ wireNavigate() }}>
General
</a>
<a class="{{ request()->routeIs('source.github.permissions-events') ? 'dark:text-white' : '' }}"
href="{{ route('source.github.permissions-events', ['github_app_uuid' => data_get($github_app, 'uuid')]) }}"
{{ wireNavigate() }}>
Permissions & Events
</a>
<a class="{{ request()->routeIs('source.github.resources') ? 'dark:text-white' : '' }}"
href="{{ route('source.github.resources', ['github_app_uuid' => data_get($github_app, 'uuid')]) }}"
{{ wireNavigate() }}>
Resources
</a>
</nav>
</div>
<livewire:source.github.tabs.resources :github-app-uuid="data_get($github_app, 'uuid')"
:key="'source-github-tab-resources-'.data_get($github_app, 'uuid')" />
</div>

View file

@ -0,0 +1,118 @@
<div>
<form wire:submit='submit' class="flex flex-col gap-2">
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<h2>General</h2>
<div class="flex gap-2">
@if (data_get($github_app, 'installation_id'))
<x-forms.button canGate="update" :canResource="$github_app" type="submit">Save</x-forms.button>
@endif
@can('delete', $github_app)
@if ($applications->count() > 0)
<x-modal-confirmation title="Confirm GitHub App Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="['The selected GitHub App will be permanently deleted.']"
confirmationText="{{ data_get($github_app, 'name') }}"
confirmationLabel="Please confirm the execution of the actions by entering the GitHub App Name below"
shortConfirmationLabel="GitHub App Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@else
<x-modal-confirmation title="Confirm GitHub App Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="['The selected GitHub App will be permanently deleted.']"
confirmationLabel="Please confirm the execution of the actions by entering the GitHub App Name below"
shortConfirmationLabel="GitHub App Name" confirmationText="{{ data_get($github_app, 'name') }}"
:confirmWithPassword="false" step2ButtonText="Permanently Delete" />
@endif
@endcan
</div>
</div>
@if (!data_get($github_app, 'installation_id'))
<div class="rounded-sm alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 stroke-current shrink-0" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>You must complete this step before you can use this source!</span>
</div>
<a class="items-center justify-center coolbox" href="{{ getInstallationPath($github_app) }}">
Install Repositories on GitHub
</a>
@else
<div class="flex flex-col gap-2">
<div class="flex flex-col sm:flex-row gap-2">
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2 w-full">
<x-forms.input canGate="update" :canResource="$github_app" id="name" label="App Name" />
<x-forms.button canGate="update" :canResource="$github_app" wire:click.prevent="updateGithubAppName">
Sync Name
</x-forms.button>
@can('update', $github_app)
<a href="{{ $this->getGithubAppNameUpdatePath() }}">
<x-forms.button
class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline">
Rename
<x-external-link />
</x-forms.button>
</a>
<a href="{{ getInstallationPath($github_app) }}" class="w-fit">
<x-forms.button
class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline whitespace-nowrap">
Update Repositories
<x-external-link />
</x-forms.button>
</a>
@endcan
</div>
</div>
<x-forms.input canGate="update" :canResource="$github_app" id="organization" label="Organization"
placeholder="If empty, personal user will be used" />
@if (!isCloud())
<div class="w-48">
<x-forms.checkbox canGate="update" :canResource="$github_app" label="System Wide?"
helper="If checked, this GitHub App will be available for everyone in this Coolify instance."
instantSave id="isSystemWide" />
</div>
@if ($isSystemWide)
<x-callout type="warning" title="Not Recommended">
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.
</x-callout>
@endif
@endif
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="htmlUrl" label="HTML Url" />
<x-forms.input canGate="update" :canResource="$github_app" id="apiUrl" label="API Url" />
</div>
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="customUser" label="User"
required />
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="customPort"
label="Port" required />
</div>
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="appId"
label="App Id" required />
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="installationId"
label="Installation Id" required />
</div>
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="clientId" label="Client Id"
type="password" required />
<x-forms.input canGate="update" :canResource="$github_app" id="clientSecret"
label="Client Secret" type="password" required />
<x-forms.input canGate="update" :canResource="$github_app" id="webhookSecret"
label="Webhook Secret" type="password" required />
</div>
<div class="flex gap-2">
<x-forms.select canGate="update" :canResource="$github_app" id="privateKeyId" label="Private Key"
required>
@if (blank($github_app->private_key_id))
<option value="0" selected>Select a private key</option>
@endif
@foreach ($privateKeys as $privateKey)
<option value="{{ $privateKey->id }}">{{ $privateKey->name }}</option>
@endforeach
</x-forms.select>
</div>
</div>
@endif
</form>
</div>

View file

@ -0,0 +1,55 @@
<div>
<div class="flex flex-col gap-2">
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2">
<h2>Permissions & Events</h2>
@if (data_get($github_app, 'installation_id'))
@can('view', $github_app)
<x-forms.button wire:click.prevent="checkPermissions">Refetch</x-forms.button>
<a href="{{ getPermissionsPath($github_app) }}">
<x-forms.button>
Update
<x-external-link />
</x-forms.button>
</a>
@endcan
@endif
</div>
@if (!data_get($github_app, 'installation_id'))
<div class="text-sm opacity-70">
Install the GitHub App first to manage permissions and webhook events.
</div>
@else
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input id="contents" helper="read - mandatory." label="Content" readonly placeholder="N/A" />
<x-forms.input id="metadata" helper="read - mandatory." label="Metadata" readonly placeholder="N/A" />
<x-forms.input id="pullRequests"
helper="write access needed to use deployment status update in previews." label="Pull Request"
readonly placeholder="N/A" />
<x-forms.input id="organizationSelfHostedRunners"
helper="write access needed to use GitHub Actions self-hosted runners." label="Runners" readonly
placeholder="N/A" />
</div>
<h3 class="pt-4">Webhook Events</h3>
@if ($webhookEvents)
<div class="flex flex-wrap gap-2">
@foreach ($webhookEvents as $event)
<span class="px-2 py-1 text-xs font-mono rounded dark:bg-coolgray-200 bg-neutral-200">{{ $event }}</span>
@endforeach
</div>
@php
$missingEvents = $github_app->missingWebhookEvents();
@endphp
@if (!empty($missingEvents))
<div class="text-xs text-warning">
Missing required events (will be auto-enabled on Refetch): {{ implode(', ', $missingEvents) }}
</div>
@endif
@else
<div class="text-xs opacity-70">
No webhook event data yet. Click Refetch above to fetch current events.
</div>
@endif
@endif
</div>
</div>

View file

@ -0,0 +1,62 @@
<div>
<div class="h-full pt-6">
<div class="flex flex-col">
<div class="flex gap-2">
<h2>Resources</h2>
</div>
<div class="pb-4 title">Here you can find all resources that are using this source.</div>
</div>
@if (!data_get($github_app, 'installation_id'))
<div class="text-sm opacity-70">
Install the GitHub App first to link resources.
</div>
@elseif ($applications->isEmpty())
<div class="py-4 text-sm opacity-70">
No resources are currently using this GitHub App.
</div>
@else
<div class="flex flex-col">
<div class="flex flex-col">
<div class="overflow-x-auto">
<div class="inline-block min-w-full">
<div class="overflow-hidden">
<table class="min-w-full">
<thead>
<tr>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Project</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Environment</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Name</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Type</th>
</tr>
</thead>
<tbody class="divide-y">
@foreach ($applications->sortBy('name', SORT_NATURAL) as $resource)
<tr>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource->project(), 'name') }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource, 'environment.name') }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
<a class="" {{ wireNavigate() }} href="{{ $resource->link() }}">
{{ $resource->name }}
<x-internal-link />
</a>
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ str($resource->type())->headline() }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@endif
</div>
</div>

View file

@ -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 () {

View file

@ -0,0 +1,56 @@
<?php
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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]));
});