fix(security): preserve private key editor modal identity (#11497)

This commit is contained in:
Andras Bacsai 2026-08-25 20:51:59 +02:00 committed by GitHub
parent 45f9a33985
commit 63cbdd5c52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 179 additions and 31 deletions

View file

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

View file

@ -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);
}

View file

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

View file

@ -22,7 +22,11 @@
@endif
</div>
@endif
@if ($type === 'password')
@if ($loading)
<div class="{{ $defaultClass }} flex w-full items-center text-neutral-500 dark:text-fg-dim" aria-busy="true">
<x-loading :text="$loadingText" />
</div>
@elseif ($type === 'password')
<div class="relative" x-data="{ type: 'password' }" @success.window="type = 'password'">
<input autocomplete="{{ $autocomplete }}" value="{{ $value }}"
x-bind:type="type"

View file

@ -149,10 +149,8 @@ class="w-full">
</label>
<div class="relative">
@if (!$valuesLoaded)
<div class="input input-with-password-toggle flex w-full items-center text-neutral-500 dark:text-fg-dim"
aria-busy="true">
<x-loading text="Loading value..." />
</div>
<x-forms.input loading loadingText="Loading value..."
defaultClass="input input-with-password-toggle" />
@else
<x-forms.env-var-input id="value" type="password"
:required="$is_redis_credential" :disabled="!$canEditValue"

View file

@ -76,9 +76,8 @@ class="listbox-option justify-start! gap-2.5!" role="menuitem">
</div>
@foreach ($privateKeys as $key)
@can('view', $key)
<x-modal-input title="Edit Private Key" isFullWidth :wireIgnore="false" :contentClicks="false"
<div wire:key="private-key-{{ $key->id }}"
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<x-slot:content>
<div
class="grid min-h-14 w-full grid-cols-[minmax(0,1fr)_7rem_1.75rem] items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-neutral-50 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_7rem_1.75rem] dark:hover:bg-white/[0.025]"
>
@ -104,14 +103,13 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg border border
@endif
</div>
<button type="button" class="icon-button" title="Edit private key"
aria-label="Edit {{ $key->name }}" @click="modalOpen=true">
aria-label="Edit {{ $key->name }}"
@click="$dispatch('open-private-key-editor', { name: @js($key->name), description: @js($key->description ?? '') })"
wire:click="openEditor('{{ $key->uuid }}')">
<x-reicon name="settings" class="size-3.5" />
</button>
</div>
</x-slot:content>
<livewire:security.private-key.show :private_key_uuid="$key->uuid" :modalMode="true"
:key="'private-key-editor-'.$key->uuid" />
</x-modal-input>
</div>
@else
<div class="grid min-h-14 cursor-not-allowed grid-cols-[minmax(0,1fr)_7rem_1.75rem] items-center gap-3 border-b border-neutral-200 px-4 py-2.5 opacity-65 last:border-b-0 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_7rem_1.75rem] dark:border-white/[0.07]"
title="You do not have permission to view this private key">
@ -139,6 +137,39 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg border border
@endforeach
</div>
@endif
<x-modal-input title="Edit Private Key" :wireIgnore="false" :contentClicks="false"
@open-private-key-editor.window="modalOpen=true; $nextTick(() => { $refs.loadingPrivateKeyName.value = $event.detail.name; $refs.loadingPrivateKeyDescription.value = $event.detail.description })">
<x-slot:content><span class="hidden" aria-hidden="true"></span></x-slot:content>
<div wire:loading.flex wire:target="openEditor" aria-label="Loading private key editor"
class="w-full flex-col gap-4">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input label="Name" required x-ref="loadingPrivateKeyName" />
<x-forms.input label="Description" x-ref="loadingPrivateKeyDescription" />
<div class="lg:col-span-2">
<x-forms.input label="Public key" loading
helper="Copy this value to ~/.ssh/authorized_keys on the target server." />
</div>
<div class="lg:col-span-2">
<div class="mb-1.5 flex items-center justify-between gap-3">
<label class="text-[13px] font-medium">Private key <span class="text-helper">*</span></label>
<span class="text-[11px] font-medium text-neutral-400 dark:text-fg-faint">Edit key</span>
</div>
<x-forms.input loading :allowToPeak="false" />
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<x-forms.button disabled isError>Delete</x-forms.button>
<x-forms.button disabled isHighlighted>Save changes</x-forms.button>
</div>
</div>
@if ($selectedPrivateKeyUuid)
<div wire:loading.remove wire:target="openEditor">
<livewire:security.private-key.show :private_key_uuid="$selectedPrivateKeyUuid" :modalMode="true"
:key="'private-key-editor-'.$selectedPrivateKeyUuid" />
</div>
@endif
</x-modal-input>
</x-application.settings-section>
</x-security.settings-layout>

View file

@ -1,6 +1,8 @@
<div x-init="$wire.loadPublicKey()">
<div>
@if ($modalMode)
<form wire:submit="changePrivateKey" class="flex flex-col gap-4" x-data="{ showPrivateKey: false }">
<div class="relative">
<form wire:submit="changePrivateKey" class="flex flex-col gap-4" x-data="{ showPrivateKey: false }"
wire:loading.class="pointer-events-none opacity-50" wire:target="delete">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input canGate="update" :canResource="$private_key" id="name" label="Name" required />
<x-forms.input canGate="update" :canResource="$private_key" id="description" label="Description" />
@ -34,6 +36,11 @@
<x-forms.button type="submit" isHighlighted>Save changes</x-forms.button>
</div>
</form>
<div wire:loading.flex wire:target="delete"
class="absolute inset-0 z-10 items-center justify-center rounded-lg bg-white/50 dark:bg-black/40">
<x-loading text="Deleting private key..." />
</div>
</div>
@else
<x-slot:title>
{{ $private_key->name }} | Private Keys | Coolify

View file

@ -29,6 +29,8 @@
->toContain('wireOpen="editorOpen"')
->toContain('$valuesLoaded')
->toContain('Loading value...');
expect($view)->toContain('<x-forms.input loading loadingText="Loading value..."');
});
it('keeps the environment variable delete button compact', function () {

View file

@ -70,8 +70,8 @@
'modalMode' => true,
])->set('name', 'Updated SSH key')
->call('changePrivateKey')
->assertDispatched('securityResourceChanged')
->assertDispatched('close-modal');
->assertDispatched('privateKeyUpdated')
->assertNoRedirect();
expect($privateKey->fresh()->name)->toBe('Updated SSH key');
});

View file

@ -1,11 +1,15 @@
<?php
use App\Livewire\Security\CloudInitScript\Show as CloudInitScriptShow;
use App\Livewire\Security\PrivateKey\Index as PrivateKeyIndex;
use App\Livewire\Security\PrivateKey\Show as PrivateKeyShow;
use App\Models\CloudInitScript;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Once;
use Livewire\Livewire;
@ -22,6 +26,7 @@
$this->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('<x-modal-input title="Edit')
->toContain('<x-reicon name="settings"')
->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</div>', '>Status</div>');
expect(file_get_contents($views[0]))
->toContain('>Private key</div>', '>Status</div>')
->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('<x-forms.input label="Public key" loading')
->toContain('<x-forms.input loading :allowToPeak="false" />')
->toContain('<x-forms.input label="Name" required x-ref="loadingPrivateKeyName" />')
->toContain('<x-forms.input label="Description" x-ref="loadingPrivateKeyDescription" />')
->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]), '<livewire:security.private-key.show'))->toBe(1);
expect(file_get_contents($views[1]))->toContain('>Token</div>', '>Provider</div>');
expect(file_get_contents($views[2]))->toContain('>Script</div>', '>Last updated</div>');
@ -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('<x-loading text="Deleting private key..." />');
});