diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 8b170e6ae..9a7ff4e97 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -10,13 +10,38 @@ class Index extends Component { use AuthorizesRequests; + public ?string $selectedPrivateKeyUuid = null; + public function getListeners(): array { return [ 'securityResourceChanged' => '$refresh', + 'privateKeyCreated' => 'refreshResources', + 'privateKeyDeleted' => 'refreshResources', + 'privateKeyUpdated' => 'refreshResources', + 'modalClosed' => 'closeEditor', ]; } + public function openEditor(string $privateKeyUuid): void + { + $privateKey = PrivateKey::ownedByCurrentTeam()->whereUuid($privateKeyUuid)->firstOrFail(); + $this->authorize('view', $privateKey); + + $this->selectedPrivateKeyUuid = $privateKey->uuid; + } + + public function closeEditor(): void + { + $this->selectedPrivateKeyUuid = null; + } + + public function refreshResources(): void + { + $this->closeEditor(); + $this->dispatch('close-modal'); + } + public function generatePrivateKey(string $type) { try { diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index 7fa230003..664119bad 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -92,6 +92,7 @@ public function mount(?string $private_key_uuid = null, bool $modalMode = false) $this->syncData(false); $this->isInUse = $this->private_key->isInUse(); + $this->public_key = $this->private_key->getPublicKey(); } catch (AuthorizationException $e) { abort(403, 'You do not have permission to view this private key.'); } catch (\Throwable) { @@ -99,14 +100,6 @@ public function mount(?string $private_key_uuid = null, bool $modalMode = false) } } - public function loadPublicKey() - { - $this->public_key = $this->private_key->getPublicKey(); - if ($this->public_key === 'Error loading private key') { - $this->dispatch('error', 'Failed to load public key. The private key may be invalid.'); - } - } - public function delete() { try { @@ -123,8 +116,7 @@ public function delete() currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); if ($this->modalMode) { - $this->dispatch('securityResourceChanged'); - $this->dispatch('close-modal'); + $this->dispatch('privateKeyDeleted'); return null; } @@ -150,10 +142,12 @@ public function changePrivateKey() ]); refresh_server_connection($this->private_key); $this->dispatch('success', 'Private key updated.'); - $this->dispatch('securityResourceChanged'); if ($this->modalMode) { - $this->dispatch('close-modal'); + $this->dispatch('privateKeyUpdated'); + + return null; } + $this->dispatch('securityResourceChanged'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php index 7831c9f02..a1b01e280 100644 --- a/app/View/Components/Forms/Input.php +++ b/app/View/Components/Forms/Input.php @@ -34,6 +34,8 @@ public function __construct( public ?string $canGate = null, public mixed $canResource = null, public bool $autoDisable = true, + public bool $loading = false, + public string $loadingText = 'Loading...', ) { // Handle authorization-based disabling if ($this->canGate && $this->canResource && $this->autoDisable) { diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index 2f9633164..f49864ddf 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -22,7 +22,11 @@ @endif @endif - @if ($type === 'password') + @if ($loading) +
+ +
+ @elseif ($type === 'password')
@if (!$valuesLoaded) -
- -
+ @else
@foreach ($privateKeys as $key) @can('view', $key) - -
@@ -104,14 +103,13 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg border border @endif
- - - + @else
@@ -139,6 +137,39 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg border border @endforeach
@endif + + + +
+
+ + +
+ +
+
+
+ + Edit key +
+ +
+
+
+ Delete + Save changes +
+
+ @if ($selectedPrivateKeyUuid) +
+ +
+ @endif +
diff --git a/resources/views/livewire/security/private-key/show.blade.php b/resources/views/livewire/security/private-key/show.blade.php index 59cffc70f..da9373f0f 100644 --- a/resources/views/livewire/security/private-key/show.blade.php +++ b/resources/views/livewire/security/private-key/show.blade.php @@ -1,6 +1,8 @@ -
+
@if ($modalMode) -
+
+
@@ -34,6 +36,11 @@ Save changes
+
+ +
+
@else {{ $private_key->name }} | Private Keys | Coolify diff --git a/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php b/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php index c98a200ca..77df86e2d 100644 --- a/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php +++ b/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php @@ -29,6 +29,8 @@ ->toContain('wireOpen="editorOpen"') ->toContain('$valuesLoaded') ->toContain('Loading value...'); + + expect($view)->toContain(' true, ])->set('name', 'Updated SSH key') ->call('changePrivateKey') - ->assertDispatched('securityResourceChanged') - ->assertDispatched('close-modal'); + ->assertDispatched('privateKeyUpdated') + ->assertNoRedirect(); expect($privateKey->fresh()->name)->toBe('Updated SSH key'); }); diff --git a/tests/Feature/SecurityResourceModalEditorsTest.php b/tests/Feature/SecurityResourceModalEditorsTest.php index b2cb0a588..06d07a167 100644 --- a/tests/Feature/SecurityResourceModalEditorsTest.php +++ b/tests/Feature/SecurityResourceModalEditorsTest.php @@ -1,11 +1,15 @@ actingAs($user); session(['currentTeam' => $team]); $this->team = $team; + Storage::fake('ssh-keys'); }); it('opens security resources in modal editors and keeps create actions in card headers', function () { @@ -40,12 +45,24 @@ ->toContain('toContain(':contentClicks="false"') - ->toContain('@click="modalOpen=true"') - ->not->toContain('wire:click="openEditor(') ->not->toContain('href="{{ route(\'security.'); } - expect(file_get_contents($views[0]))->toContain('>Private key
', '>Status
'); + expect(file_get_contents($views[0])) + ->toContain('>Private key', '>Status') + ->toContain('wire:click="openEditor(\'{{ $key->uuid }}\')"') + ->toContain("\$dispatch('open-private-key-editor', { name:") + ->toContain('$refs.loadingPrivateKeyName.value = $event.detail.name') + ->toContain('wire:loading.flex wire:target="openEditor"') + ->toContain('aria-label="Loading private key editor"') + ->toContain('class="w-full flex-col gap-4"') + ->toContain('toContain('') + ->toContain('') + ->toContain('') + ->not->toContain('class="flex flex-col gap-1.5 lg:col-span-2"') + ->not->toContain('animate-pulse') + ->and(substr_count(file_get_contents($views[0]), 'toBe(1); expect(file_get_contents($views[1]))->toContain('>Token', '>Provider'); expect(file_get_contents($views[2]))->toContain('>Script', '>Last updated'); @@ -82,3 +99,71 @@ $this->assertModelMissing($script); }); + +it('keeps the remaining private key editor populated after deleting multiple keys', function () { + $privateKeys = collect(range(1, 3))->map(fn (int $index) => PrivateKey::factory()->create([ + 'name' => "private-key-regression-marker-{$index}", + 'team_id' => $this->team->id, + 'private_key' => PrivateKey::generateNewKeyPair('ed25519')['private_key'], + ])); + $index = Livewire::test(PrivateKeyIndex::class); + + foreach ($privateKeys->take(2) as $privateKey) { + Livewire::test(PrivateKeyShow::class, [ + 'private_key_uuid' => $privateKey->uuid, + 'modalMode' => true, + ])->call('delete') + ->assertDispatched('privateKeyDeleted') + ->assertNoRedirect(); + } + + $remainingPrivateKey = $privateKeys->last(); + + $index->dispatch('securityResourceChanged') + ->assertDontSee($privateKeys->get(0)->name) + ->assertDontSee($privateKeys->get(1)->name) + ->assertSee($remainingPrivateKey->name); + + Livewire::test(PrivateKeyShow::class, [ + 'private_key_uuid' => $remainingPrivateKey->uuid, + 'modalMode' => true, + ])->assertSet('name', $remainingPrivateKey->name) + ->assertSee($remainingPrivateKey->name); +}); + +it('loads only the selected private key editor and refreshes mutations without navigation', function () { + $privateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); + + Livewire::test(PrivateKeyIndex::class) + ->call('openEditor', $privateKey->uuid) + ->assertSet('selectedPrivateKeyUuid', $privateKey->uuid) + ->dispatch('modalClosed') + ->assertSet('selectedPrivateKeyUuid', null) + ->dispatch('privateKeyCreated', keyId: $privateKey->id) + ->assertNoRedirect(); +}); + +it('loads the public key with the editor instead of making a follow-up request', function () { + $privateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); + + Livewire::test(PrivateKeyShow::class, [ + 'private_key_uuid' => $privateKey->uuid, + 'modalMode' => true, + ])->assertSet('public_key', $privateKey->getPublicKey()); + + expect(file_get_contents(resource_path('views/livewire/security/private-key/show.blade.php'))) + ->not->toContain('x-init="$wire.loadPublicKey()"'); +}); + +it('shows deletion progress in the underlying private key editor', function () { + $view = file_get_contents(resource_path('views/livewire/security/private-key/show.blade.php')); + + expect($view) + ->toContain('wire:loading.class="pointer-events-none opacity-50" wire:target="delete"') + ->toContain('wire:loading.flex wire:target="delete"') + ->toContain(''); +});