fix(security): preserve private key editor modal identity (#11497)
This commit is contained in:
parent
45f9a33985
commit
63cbdd5c52
10 changed files with 179 additions and 31 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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..." />');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue