From 63cbdd5c520cd2dbcd2743881bde251b5a52295d Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 25 Aug 2026 20:51:59 +0200
Subject: [PATCH] fix(security): preserve private key editor modal identity
(#11497)
---
app/Livewire/Security/PrivateKey/Index.php | 25 +++++
app/Livewire/Security/PrivateKey/Show.php | 18 ++--
app/View/Components/Forms/Input.php | 2 +
.../views/components/forms/input.blade.php | 6 +-
.../environment-variable/show.blade.php | 6 +-
.../security/private-key/index.blade.php | 45 +++++++--
.../security/private-key/show.blade.php | 11 ++-
...ronmentVariableMultilineToggleViewTest.php | 2 +
.../Security/PrivateKeyDropdownTest.php | 4 +-
.../SecurityResourceModalEditorsTest.php | 91 ++++++++++++++++++-
10 files changed, 179 insertions(+), 31 deletions(-)
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
+
+ { $refs.loadingPrivateKeyName.value = $event.detail.name; $refs.loadingPrivateKeyDescription.value = $event.detail.description })">
+
+
+
+
+
+
+
+
+
+
+
+ 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 @@
-');
+ 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('');
+});