diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php
index fa7397d13..826289b88 100644
--- a/app/Livewire/Security/PrivateKey/Show.php
+++ b/app/Livewire/Security/PrivateKey/Show.php
@@ -4,6 +4,7 @@
use App\Models\PrivateKey;
use App\Support\ValidationPatterns;
+use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -22,8 +23,12 @@ class Show extends Component
public bool $isGitRelated = false;
+ public bool $isInUse = false;
+
public $public_key = 'Loading...';
+ public string $deleteDisabledReason = 'This private key is currently used by a server, application, or Git app and cannot be deleted.';
+
protected function rules(): array
{
return [
@@ -74,16 +79,17 @@ private function syncData(bool $toModel = false): void
}
}
- public function mount()
+ public function mount(?string $private_key_uuid = null)
{
try {
- $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid(request()->private_key_uuid)->firstOrFail();
+ $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid($private_key_uuid ?? request()->private_key_uuid)->firstOrFail();
// Explicit authorization check - will throw 403 if not authorized
$this->authorize('view', $this->private_key);
$this->syncData(false);
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ $this->isInUse = $this->private_key->isInUse();
+ } catch (AuthorizationException $e) {
abort(403, 'You do not have permission to view this private key.');
} catch (\Throwable) {
abort(404);
@@ -102,7 +108,15 @@ public function delete()
{
try {
$this->authorize('delete', $this->private_key);
- $this->private_key->safeDelete();
+
+ if ($this->private_key->isInUse()) {
+ $this->isInUse = true;
+ $this->dispatch('error', $this->deleteDisabledReason);
+
+ return;
+ }
+
+ $this->private_key->delete();
currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get();
return redirectRoute($this, 'security.private-key.index');
diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php
index 810b95ed4..54f3436dc 100644
--- a/app/Livewire/Server/PrivateKey/Show.php
+++ b/app/Livewire/Server/PrivateKey/Show.php
@@ -55,6 +55,33 @@ public function setPrivateKey($privateKeyId)
}
}
+ public function generatePrivateKey(string $type): void
+ {
+ try {
+ $this->authorize('create', PrivateKey::class);
+
+ if (! in_array($type, ['ed25519', 'rsa'], true)) {
+ $this->dispatch('error', 'Invalid private key type.');
+
+ return;
+ }
+
+ $keyData = PrivateKey::generateNewKeyPair($type);
+ $privateKey = PrivateKey::createAndStore([
+ 'name' => $keyData['name'],
+ 'description' => $keyData['description'],
+ 'private_key' => $keyData['private_key'],
+ 'team_id' => currentTeam()->id,
+ ]);
+
+ $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get()->where('is_git_related', false);
+ $this->dispatch('copyPublicKeyToClipboard', publicKey: $privateKey->public_key);
+ $this->dispatch('success', 'Private key created successfully.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
public function checkConnection()
{
try {
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index 46f562a9e..15af859c9 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -828,6 +828,7 @@ public function linkToHetzner()
$this->hetznerSearchError = null;
$this->dispatch('success', 'Server successfully linked to Hetzner Cloud!');
+ $this->dispatch('close-modal');
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -958,6 +959,7 @@ public function linkToDigitalOcean()
$this->digitalOceanSearchError = null;
$this->dispatch('success', 'Server successfully linked to DigitalOcean!');
+ $this->dispatch('close-modal');
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -1082,6 +1084,7 @@ public function linkToVultr()
$this->vultrSearchError = null;
$this->dispatch('success', 'Server successfully linked to Vultr!');
+ $this->dispatch('close-modal');
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
return handleError($e, $this);
diff --git a/app/View/Components/Forms/Button.php b/app/View/Components/Forms/Button.php
index 8511c87db..f03b36f0e 100644
--- a/app/View/Components/Forms/Button.php
+++ b/app/View/Components/Forms/Button.php
@@ -22,6 +22,7 @@ public function __construct(
public ?string $canGate = null,
public mixed $canResource = null,
public bool $autoDisable = true,
+ public ?string $tooltip = null,
) {
// Handle authorization-based disabling
if ($this->canGate && $this->canResource && $this->autoDisable) {
diff --git a/resources/views/components/forms/button.blade.php b/resources/views/components/forms/button.blade.php
index f1efd8c6c..89b177c1f 100644
--- a/resources/views/components/forms/button.blade.php
+++ b/resources/views/components/forms/button.blade.php
@@ -1,4 +1,4 @@
-@if ($authDisabled)
+@if ($authDisabled || filled($tooltip))
{
@@ -39,9 +39,9 @@
@endif
@endif
-@if ($authDisabled)
+@if ($authDisabled || filled($tooltip))
- You do not have permission to perform this action.
+ {{ $tooltip ?: 'You do not have permission to perform this action.' }}
@endif
diff --git a/resources/views/components/helper.blade.php b/resources/views/components/helper.blade.php
index 2102e48b1..900beddd0 100644
--- a/resources/views/components/helper.blade.php
+++ b/resources/views/components/helper.blade.php
@@ -1,6 +1,47 @@
-
merge(['class' => 'group relative inline-block align-middle']) }}>
-
+
merge(['class' => 'inline-block align-middle']) }}>
+
@isset($icon)
{{ $icon }}
@else
@@ -9,11 +50,13 @@
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z">
@endisset
-
-
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php
index 5efc9102b..830b7e09c 100644
--- a/resources/views/components/modal-confirmation.blade.php
+++ b/resources/views/components/modal-confirmation.blade.php
@@ -6,6 +6,7 @@
'buttonFullWidth' => false,
'customButton' => null,
'disabled' => false,
+ 'disabledTooltip' => null,
'authDisabled' => false,
'dispatchAction' => false,
'submitAction' => 'delete',
@@ -156,11 +157,11 @@
@else
@if ($disabled)
@if ($buttonFullWidth)
-
+
{{ $buttonTitle }}
@else
-
+
{{ $buttonTitle }}
@endif
diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php
index 0f7347e5c..96bb3467c 100644
--- a/resources/views/components/modal-input.blade.php
+++ b/resources/views/components/modal-input.blade.php
@@ -8,6 +8,7 @@
'content' => null,
'closeOutside' => true,
'isFullWidth' => false,
+ 'wireIgnore' => true,
])
@php
@@ -17,7 +18,7 @@
+ class="relative w-auto h-auto" @close-modal.window="modalOpen=false" @if ($wireIgnore) wire:ignore @endif>
@if ($content)
{{ $content }}
diff --git a/resources/views/livewire/security/private-key/show.blade.php b/resources/views/livewire/security/private-key/show.blade.php
index da94f3c48..db7218624 100644
--- a/resources/views/livewire/security/private-key/show.blade.php
+++ b/resources/views/livewire/security/private-key/show.blade.php
@@ -19,7 +19,7 @@ class="w-fit rounded-sm border border-coollabs bg-coollabs-50 px-2 py-1 text-xs
@if (data_get($private_key, 'id') > 0)
@can('delete', $private_key)
id }})" :disabled="$isInUse" :disabledTooltip="$deleteDisabledReason" :actions="[
'This private key will be permanently deleted.',
'All servers connected to this private key will stop working.',
'Any git app using this private key will stop working.',
diff --git a/resources/views/livewire/server/private-key/show.blade.php b/resources/views/livewire/server/private-key/show.blade.php
index 495e1596d..70dc7f587 100644
--- a/resources/views/livewire/server/private-key/show.blade.php
+++ b/resources/views/livewire/server/private-key/show.blade.php
@@ -9,9 +9,54 @@
Private Key
@can('createAnyResource')
-
-
-
+
@endcan
Check connection
@@ -26,15 +71,21 @@ class="box-without-bg justify-between dark:bg-coolgray-100 bg-white items-center
{{ $private_key->name }}
{{ $private_key->description }}
- @if (data_get($server, 'privateKey.uuid') !== $private_key->uuid)
-
- Use this key
+
+ public_key) }})">
+ Copy public key
- @else
-
- Currently used
-
- @endif
+ @if (data_get($server, 'privateKey.uuid') !== $private_key->uuid)
+
+ Use this key
+
+ @else
+
+ Currently used
+
+ @endif
+
@empty
No private keys found.
@@ -42,4 +93,31 @@ class="box-without-bg justify-between dark:bg-coolgray-100 bg-white items-center
+
+ @script
+
+ @endscript
diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php
index d03d10dfb..f8f231e4c 100644
--- a/resources/views/livewire/server/show.blade.php
+++ b/resources/views/livewire/server/show.blade.php
@@ -212,6 +212,11 @@ class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-warn
Validating...
@endif
+ @php
+ $hasLinkableCloudProviders = (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
+ || (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
+ || (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty());
+ @endphp
@if ($server->id === 0)
Save
+ @if ($hasLinkableCloudProviders)
+
+
+ Link Cloud Provider
+
+
+
+
+
+
+
+ @if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
+
+
+
+
+
+
+ Link this server to a Hetzner Cloud instance to enable power controls and status monitoring.
+
+
+
+ Select a token...
+ @foreach ($availableHetznerTokens as $token)
+ {{ $token->name }}
+ @endforeach
+
+
+
+
+
+
+
+
+ Search
+ Searching...
+
+
+
+
+ Search by IP
+ Searching...
+
+
+ @if ($hetznerSearchError)
+
+
{{ $hetznerSearchError }}
+
+ @endif
+ @if ($hetznerNoMatchFound)
+
+
+ @if ($manualHetznerServerId)
+ No Hetzner server found with ID: {{ $manualHetznerServerId }}
+ @else
+ No Hetzner server found matching IP: {{ $server->ip }}
+ @endif
+
+
+ Try a different token, enter the Server ID manually, or verify the details are correct.
+
+
+ @endif
+ @if ($matchedHetznerServer)
+
+
Match Found!
+
+
Name: {{ $matchedHetznerServer['name'] }}
+
ID: {{ $matchedHetznerServer['id'] }}
+
Status: {{ ucfirst($matchedHetznerServer['status']) }}
+
Type: {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}
+
+
+ Link This Server
+
+
+ @endif
+
+
+ @endif
+ @if (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty())
+
+
+
+
+
+
+ Link this server to a DigitalOcean droplet to enable power controls and status monitoring.
+
+
+
+ Select a token...
+ @foreach ($availableDigitalOceanTokens as $token)
+ {{ $token->name }}
+ @endforeach
+
+
+
+
+
+
+
+
+ Search
+ Searching...
+
+
+
+
+ Search by IP
+ Searching...
+
+
+ @if ($digitalOceanSearchError)
+
+
{{ $digitalOceanSearchError }}
+
+ @endif
+ @if ($digitalOceanNoMatchFound)
+
+
+ @if ($manualDigitalOceanDropletId)
+ No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }}
+ @else
+ No DigitalOcean droplet found matching IP: {{ $server->ip }}
+ @endif
+
+
+ Try a different token, enter the Droplet ID manually, or verify the details are correct.
+
+
+ @endif
+ @if ($matchedDigitalOceanDroplet)
+
+
Match Found!
+
+
Name: {{ $matchedDigitalOceanDroplet['name'] ?? 'Unknown' }}
+
ID: {{ $matchedDigitalOceanDroplet['id'] }}
+
Status: {{ ucfirst($matchedDigitalOceanDroplet['status'] ?? 'unknown') }}
+
Size: {{ data_get($matchedDigitalOceanDroplet, 'size.slug', 'Unknown') }}
+
+
+ Link This Server
+
+
+ @endif
+
+
+ @endif
+ @if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
+
+
+
+
+
+
+ Link this server to a Vultr instance to enable power controls and status monitoring.
+
+
+
+ Select a token...
+ @foreach ($availableVultrTokens as $token)
+ {{ $token->name }}
+ @endforeach
+
+
+
+
+
+
+
+
+ Search
+ Searching...
+
+
+
+
+ Search by IP
+ Searching...
+
+
+ @if ($vultrSearchError)
+
+
{{ $vultrSearchError }}
+
+ @endif
+ @if ($vultrNoMatchFound)
+
+
+ @if ($manualVultrInstanceId)
+ No Vultr instance found with ID: {{ $manualVultrInstanceId }}
+ @else
+ No Vultr instance found matching IP: {{ $server->ip }}
+ @endif
+
+
+ Try a different token, enter the Instance ID manually, or verify the details are correct.
+
+
+ @endif
+ @if ($matchedVultrInstance)
+
+
Match Found!
+
+
Name: {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}
+
ID: {{ $matchedVultrInstance['id'] }}
+
Status: {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}
+
Plan: {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}
+
+
+ Link This Server
+
+
+ @endif
+
+
+ @endif
+
+
+
+
+ @endif
@if ($server->isFunctional())
Validate & configure
@@ -482,228 +777,6 @@ class="dark:hover:fill-white fill-black dark:fill-warning">
@endif
@endif
- @if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
-
-
Link to Hetzner Cloud
-
- Link this server to a Hetzner Cloud instance to enable power controls and status monitoring.
-
-
-
-
-
- Select a token...
- @foreach ($availableHetznerTokens as $token)
- {{ $token->name }}
- @endforeach
-
-
-
-
-
-
- Search by ID
- Searching...
-
-
OR
-
- Search by IP
- Searching...
-
-
-
- @if ($hetznerSearchError)
-
-
{{ $hetznerSearchError }}
-
- @endif
-
- @if ($hetznerNoMatchFound)
-
-
- @if ($manualHetznerServerId)
- No Hetzner server found with ID: {{ $manualHetznerServerId }}
- @else
- No Hetzner server found matching IP: {{ $server->ip }}
- @endif
-
-
- Try a different token, enter the Server ID manually, or verify the details are correct.
-
-
- @endif
-
- @if ($matchedHetznerServer)
-
-
Match Found!
-
-
Name: {{ $matchedHetznerServer['name'] }}
-
ID: {{ $matchedHetznerServer['id'] }}
-
Status: {{ ucfirst($matchedHetznerServer['status']) }}
-
Type: {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}
-
-
- Link This Server
-
-
- @endif
-
- @endif
- @if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
-
-
Link to Vultr
-
- Link this server to a Vultr instance to enable power controls and status monitoring.
-
-
-
-
-
- Select a token...
- @foreach ($availableVultrTokens as $token)
- {{ $token->name }}
- @endforeach
-
-
-
-
-
-
- Search by ID
- Searching...
-
-
OR
-
- Search by IP
- Searching...
-
-
-
- @if ($vultrSearchError)
-
-
{{ $vultrSearchError }}
-
- @endif
-
- @if ($vultrNoMatchFound)
-
-
- @if ($manualVultrInstanceId)
- No Vultr instance found with ID: {{ $manualVultrInstanceId }}
- @else
- No Vultr instance found matching IP: {{ $server->ip }}
- @endif
-
-
- Try a different token, enter the Instance ID manually, or verify the details are correct.
-
-
- @endif
-
- @if ($matchedVultrInstance)
-
-
Match Found!
-
-
Name: {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}
-
ID: {{ $matchedVultrInstance['id'] }}
-
Status: {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}
-
Plan: {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}
-
-
- Link This Server
-
-
- @endif
-
- @endif
- @if (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty())
-
-
Link to DigitalOcean
-
- Link this server to a DigitalOcean droplet to enable power controls and status monitoring.
-
-
-
-
-
- Select a token...
- @foreach ($availableDigitalOceanTokens as $token)
- {{ $token->name }}
- @endforeach
-
-
-
-
-
-
- Search by ID
- Searching...
-
-
OR
-
- Search by IP
- Searching...
-
-
-
- @if ($digitalOceanSearchError)
-
-
{{ $digitalOceanSearchError }}
-
- @endif
-
- @if ($digitalOceanNoMatchFound)
-
-
- @if ($manualDigitalOceanDropletId)
- No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }}
- @else
- No DigitalOcean droplet found matching IP: {{ $server->ip }}
- @endif
-
-
- @endif
-
- @if ($matchedDigitalOceanDroplet)
-
-
Match Found!
-
-
Name: {{ $matchedDigitalOceanDroplet['name'] ?? 'Unknown' }}
-
ID: {{ $matchedDigitalOceanDroplet['id'] }}
-
Status: {{ ucfirst($matchedDigitalOceanDroplet['status'] ?? 'unknown') }}
-
Size: {{ data_get($matchedDigitalOceanDroplet, 'size.slug', 'Unknown') }}
-
-
- Link This Server
-
-
- @endif
-
- @endif
diff --git a/tests/Feature/CloudProviderLinkDropdownTest.php b/tests/Feature/CloudProviderLinkDropdownTest.php
new file mode 100644
index 000000000..b05f08173
--- /dev/null
+++ b/tests/Feature/CloudProviderLinkDropdownTest.php
@@ -0,0 +1,192 @@
+ 'file',
+ 'cache.default' => 'array',
+ 'session.driver' => 'array',
+ ]);
+
+ InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
+ 'id' => 0,
+ 'is_api_enabled' => true,
+ ]));
+
+ $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]);
+
+ $this->server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'ip' => '1.2.3.4',
+ ]);
+});
+
+it('shows link cloud provider dropdown with available unlinked providers', function () {
+ CloudProviderToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'hetzner',
+ 'token' => 'test-hetzner-token',
+ 'name' => 'Test Hetzner Token',
+ ]);
+
+ CloudProviderToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'vultr',
+ 'token' => 'test-vultr-token',
+ 'name' => 'Test Vultr Token',
+ ]);
+
+ CloudProviderToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'digitalocean',
+ 'token' => 'test-digitalocean-token',
+ 'name' => 'Test DigitalOcean Token',
+ ]);
+
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->assertSee('Link Cloud Provider')
+ ->assertSee('Hetzner')
+ ->assertSee('DigitalOcean')
+ ->assertSee('Vultr')
+ ->assertSee('Hetzner Token')
+ ->assertSee('DigitalOcean Token')
+ ->assertSee('Vultr Token')
+ ->assertSee('Server ID')
+ ->assertSee('Droplet ID')
+ ->assertSee('Instance ID')
+ ->assertSee('Search by IP')
+ ->assertSee('Search');
+});
+
+it('hides link cloud provider dropdown when no providers can be linked', function () {
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->assertDontSee('Link Cloud Provider');
+});
+
+it('does not list providers already linked to the server', function () {
+ CloudProviderToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'hetzner',
+ 'token' => 'test-hetzner-token',
+ 'name' => 'Test Hetzner Token',
+ ]);
+
+ CloudProviderToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'vultr',
+ 'token' => 'test-vultr-token',
+ 'name' => 'Test Vultr Token',
+ ]);
+
+ $this->server->update(['hetzner_server_id' => 123]);
+
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->assertSee('Link Cloud Provider')
+ ->assertSee('Vultr Token')
+ ->assertDontSee('Hetzner Token');
+});
+
+it('shows Hetzner search by IP errors in the modal', function () {
+ $token = CloudProviderToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'hetzner',
+ 'token' => 'invalid-hetzner-token',
+ 'name' => 'Invalid Hetzner Token',
+ ]);
+
+ Http::fake([
+ 'https://api.hetzner.cloud/v1/servers*' => Http::response([
+ 'error' => ['message' => 'invalid token'],
+ ], 401),
+ ]);
+
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->set('selectedHetznerTokenId', $token->id)
+ ->call('searchHetznerServer')
+ ->assertSet('hetznerSearchError', fn (string $error) => str_contains($error, 'Failed to search Hetzner servers:') && str_contains($error, 'invalid token'))
+ ->assertSee('Failed to search Hetzner servers:')
+ ->assertSee('invalid token');
+});
+
+it('shows Hetzner search by ID errors in the modal', function () {
+ $token = CloudProviderToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'hetzner',
+ 'token' => 'invalid-hetzner-token',
+ 'name' => 'Invalid Hetzner Token',
+ ]);
+
+ Http::fake([
+ 'https://api.hetzner.cloud/v1/servers/12345678' => Http::response([
+ 'error' => ['message' => 'invalid token'],
+ ], 401),
+ ]);
+
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->set('selectedHetznerTokenId', $token->id)
+ ->set('manualHetznerServerId', '12345678')
+ ->call('searchHetznerServerById')
+ ->assertSet('hetznerSearchError', fn (string $error) => str_contains($error, 'Failed to fetch Hetzner server:') && str_contains($error, 'invalid token'))
+ ->assertSee('Failed to fetch Hetzner server:')
+ ->assertSee('invalid token');
+});
+
+it('shows DigitalOcean search errors in the modal', function () {
+ $token = CloudProviderToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'digitalocean',
+ 'token' => 'invalid-digitalocean-token',
+ 'name' => 'Invalid DigitalOcean Token',
+ ]);
+
+ Http::fake([
+ 'https://api.digitalocean.com/v2/droplets*' => Http::response([
+ 'message' => 'invalid token',
+ ], 401),
+ ]);
+
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->set('selectedDigitalOceanTokenId', $token->id)
+ ->call('searchDigitalOceanDroplet')
+ ->assertSet('digitalOceanSearchError', fn (string $error) => str_contains($error, 'Failed to search DigitalOcean droplets:') && str_contains($error, 'invalid token'))
+ ->assertSee('Failed to search DigitalOcean droplets:')
+ ->assertSee('invalid token');
+});
+
+it('shows Vultr search errors in the modal', function () {
+ $token = CloudProviderToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'vultr',
+ 'token' => 'invalid-vultr-token',
+ 'name' => 'Invalid Vultr Token',
+ ]);
+
+ Http::fake([
+ 'https://api.vultr.com/v2/instances*' => Http::response([
+ 'error' => 'invalid token',
+ ], 401),
+ ]);
+
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->set('selectedVultrTokenId', $token->id)
+ ->call('searchVultrInstance')
+ ->assertSet('vultrSearchError', fn (string $error) => str_contains($error, 'Failed to search Vultr instances:') && str_contains($error, 'invalid token'))
+ ->assertSee('Failed to search Vultr instances:')
+ ->assertSee('invalid token');
+});
diff --git a/tests/Feature/Security/PrivateKeyDropdownTest.php b/tests/Feature/Security/PrivateKeyDropdownTest.php
index 6152e0c2b..5dfeb31ab 100644
--- a/tests/Feature/Security/PrivateKeyDropdownTest.php
+++ b/tests/Feature/Security/PrivateKeyDropdownTest.php
@@ -2,8 +2,10 @@
use App\Livewire\Security\PrivateKey\Create;
use App\Livewire\Security\PrivateKey\Index;
+use App\Livewire\Security\PrivateKey\Show;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
+use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -95,3 +97,38 @@
->and($view)->toContain('')
->and($badgePosition)->toBeLessThan($saveButtonPosition);
});
+
+test('used private key details disable delete with an explanation', function () {
+ $privateKey = PrivateKey::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+
+ Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $privateKey->id,
+ ]);
+
+ $this->get(route('security.private-key.show', [
+ 'private_key_uuid' => $privateKey->uuid,
+ ]))
+ ->assertSuccessful()
+ ->assertSee('This private key is currently used by a server, application, or Git app and cannot be deleted.', false)
+ ->assertSee('disabled', false);
+});
+
+test('used private key delete action keeps the key and shows an error', function () {
+ $privateKey = PrivateKey::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+
+ Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $privateKey->id,
+ ]);
+
+ Livewire::test(Show::class, ['private_key_uuid' => $privateKey->uuid])
+ ->call('delete')
+ ->assertDispatched('error');
+
+ expect($privateKey->fresh())->not->toBeNull();
+});
diff --git a/tests/Feature/ServerPrivateKeyDropdownTest.php b/tests/Feature/ServerPrivateKeyDropdownTest.php
new file mode 100644
index 000000000..a0ebcd044
--- /dev/null
+++ b/tests/Feature/ServerPrivateKeyDropdownTest.php
@@ -0,0 +1,100 @@
+whereKey(0)->exists()) {
+ $settings = new InstanceSettings;
+ $settings->id = 0;
+ $settings->save();
+ }
+
+ Once::flush();
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ session(['currentTeam' => $this->team]);
+ $this->actingAs($this->user);
+
+ Config::set('cache.default', 'array');
+ Storage::fake('ssh-keys');
+
+ $this->currentPrivateKey = PrivateKey::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+
+ $this->server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $this->currentPrivateKey->id,
+ ]);
+});
+
+test('server private key page shows highlighted add dropdown actions', function () {
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->assertSee('+ Add')
+ ->assertSee('Generate ED25519')
+ ->assertSee('Generate RSA')
+ ->assertSee('Add manually')
+ ->assertSee('Check connection');
+});
+
+test('generating a server private key stores it and refreshes the current view', function () {
+ $component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->call('generatePrivateKey', 'ed25519')
+ ->assertNoRedirect()
+ ->assertDispatched('success');
+
+ $privateKey = PrivateKey::query()
+ ->where('id', '!=', $this->currentPrivateKey->id)
+ ->firstOrFail();
+
+ expect($privateKey->team_id)->toBe($this->team->id)
+ ->and($privateKey->public_key)->toStartWith('ssh-ed25519');
+
+ $component
+ ->assertDispatched('copyPublicKeyToClipboard', publicKey: $privateKey->public_key)
+ ->assertSee($privateKey->name);
+});
+
+test('server private key page copies generated public keys and shows a copied hint', function () {
+ $view = file_get_contents(resource_path('views/livewire/server/private-key/show.blade.php'));
+
+ expect($view)->toContain('copyPublicKeyToClipboard')
+ ->and($view)->toContain('navigator.clipboard.writeText')
+ ->and($view)->toContain('Public key copied to clipboard.');
+});
+
+test('server private key cards include a copy public key button', function () {
+ $keyData = PrivateKey::generateNewKeyPair('rsa');
+
+ PrivateKey::createAndStore([
+ 'team_id' => $this->team->id,
+ 'name' => 'Alternative SSH Key',
+ 'description' => 'Created by test',
+ 'private_key' => $keyData['private_key'],
+ ]);
+
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->assertSee('Copy public key')
+ ->assertSee('Alternative SSH Key');
+
+ $view = file_get_contents(resource_path('views/livewire/server/private-key/show.blade.php'));
+
+ expect($view)->toContain('Copy public key')
+ ->and($view)->toContain('$private_key->public_key')
+ ->and($view)->toContain('Public key copied to clipboard.');
+});