feat(server): improve private key and cloud link flows

Add server private key generation from the dropdown, prevent deleting
private keys that are still in use, and close cloud provider link modals
after successful linking.
This commit is contained in:
Andras Bacsai 2026-07-08 13:35:09 +02:00
parent 9b8aeb4758
commit 0217e2b0c0
14 changed files with 822 additions and 252 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
@if ($authDisabled)
@if ($authDisabled || filled($tooltip))
<span class="relative inline-flex"
x-data="{ visible: false, _t: null }"
@mouseenter="_t = setTimeout(() => {
@ -39,9 +39,9 @@
@endif
@endif
</button>
@if ($authDisabled)
@if ($authDisabled || filled($tooltip))
<div x-ref="tip" x-show="visible" x-cloak class="auth-tooltip">
You do not have permission to perform this action.
{{ $tooltip ?: 'You do not have permission to perform this action.' }}
</div>
</span>
@endif

View file

@ -1,6 +1,47 @@
<div x-data="{ open: false }" @click.stop="open = !open" @click.outside="open = false"
{{ $attributes->merge(['class' => 'group relative inline-block align-middle']) }}>
<div class="info-helper">
<div x-data="{
open: false,
pinned: false,
style: '',
show(pinned = false) {
this.pinned = pinned;
this.open = true;
this.$nextTick(() => this.position());
},
hide() {
if (!this.pinned) {
this.open = false;
}
},
close() {
this.pinned = false;
this.open = false;
},
position() {
const trigger = this.$refs.trigger;
const popup = this.$refs.popup;
if (!trigger || !popup) {
return;
}
const triggerRect = trigger.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
const padding = 8;
let top = triggerRect.bottom + padding;
let left = triggerRect.right - popupRect.width;
if (top + popupRect.height > window.innerHeight - padding) {
top = triggerRect.top - popupRect.height - padding;
}
left = Math.min(Math.max(padding, left), window.innerWidth - popupRect.width - padding);
top = Math.max(padding, top);
this.style = `top: ${top}px; left: ${left}px;`;
}
}" @click.outside="close" @keydown.window.escape="close" @resize.window="open && position()" @scroll.window="open && position()"
{{ $attributes->merge(['class' => 'inline-block align-middle']) }}>
<div x-ref="trigger" class="info-helper" @mouseenter="show(false)" @mouseleave="hide" @click.stop="open && pinned ? close() : show(true)">
@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"></path>
</svg>
@endisset
</div>
<div class="info-helper-popup" :class="{ 'block': open }">
<div class="p-4">
{!! $helper !!}
<template x-teleport="body">
<div x-ref="popup" x-show="open" x-cloak :style="style"
class="fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))] rounded-sm border border-neutral-300 bg-neutral-200 text-xs text-neutral-700 shadow-lg whitespace-normal break-words dark:border-coolgray-500 dark:bg-coolgray-400 dark:text-neutral-300">
<div class="p-4">
{!! $helper !!}
</div>
</div>
</div>
</template>
</div>

View file

@ -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)
<x-forms.button class="w-full" isError disabled :authDisabled="$authDisabled" wire:target>
<x-forms.button class="w-full" isError disabled :authDisabled="$authDisabled" :tooltip="$disabledTooltip" wire:target>
{{ $buttonTitle }}
</x-forms.button>
@else
<x-forms.button isError disabled :authDisabled="$authDisabled" wire:target>
<x-forms.button isError disabled :authDisabled="$authDisabled" :tooltip="$disabledTooltip" wire:target>
{{ $buttonTitle }}
</x-forms.button>
@endif

View file

@ -8,6 +8,7 @@
'content' => null,
'closeOutside' => true,
'isFullWidth' => false,
'wireIgnore' => true,
])
@php
@ -17,7 +18,7 @@
<div x-data="{ modalOpen: false }"
x-init="$watch('modalOpen', value => { if (!value) { $wire.dispatch('modalClosed') } })"
:class="{ 'z-40': modalOpen }" @keydown.window.escape="modalOpen=false"
class="relative w-auto h-auto" @close-modal.window="modalOpen=false" wire:ignore>
class="relative w-auto h-auto" @close-modal.window="modalOpen=false" @if ($wireIgnore) wire:ignore @endif>
@if ($content)
<div @click="modalOpen=true">
{{ $content }}

View file

@ -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)
<x-modal-confirmation title="Confirm Private Key Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete({{ $private_key->id }})" :actions="[
submitAction="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.',

View file

@ -9,9 +9,54 @@
<div class="flex items-end gap-2">
<h2>Private Key</h2>
@can('createAnyResource')
<x-modal-input buttonTitle="+ Add" title="New Private Key">
<livewire:security.private-key.create />
</x-modal-input>
<div x-data="{ dropdownOpen: false }" class="relative w-fit" @click.outside="dropdownOpen = false">
<x-forms.button isHighlighted @click="dropdownOpen = !dropdownOpen" type="button">
+ Add
<svg class="w-4 h-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
</svg>
</x-forms.button>
<div x-show="dropdownOpen" @click.away="dropdownOpen=false" x-transition:enter="ease-out duration-200"
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
class="absolute top-0 z-50 mt-10 min-w-max" x-cloak>
<div
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
<div class="flex flex-col gap-1">
<a class="dropdown-item" wire:click="generatePrivateKey('ed25519')"
@click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Generate ED25519
</a>
<a class="dropdown-item" wire:click="generatePrivateKey('rsa')"
@click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Generate RSA
</a>
<x-modal-input title="Add Private Key Manually">
<x-slot:content>
<div class="dropdown-item" @click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add manually
</div>
</x-slot:content>
<livewire:security.private-key.create />
</x-modal-input>
</div>
</div>
</div>
</div>
@endcan
<x-forms.button canGate="update" :canResource="$server" isHighlighted wire:click.prevent='checkConnection'>
Check connection
@ -26,15 +71,21 @@ class="box-without-bg justify-between dark:bg-coolgray-100 bg-white items-center
<div class="box-title">{{ $private_key->name }}</div>
<div class="box-description">{{ $private_key->description }}</div>
</div>
@if (data_get($server, 'privateKey.uuid') !== $private_key->uuid)
<x-forms.button canGate="update" :canResource="$server" class="w-full" wire:click='setPrivateKey({{ $private_key->id }})'>
Use this key
<div class="grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
<x-forms.button class="w-full"
@click.prevent="copyPublicKeyToClipboard({{ Js::from($private_key->public_key) }})">
Copy public key
</x-forms.button>
@else
<x-forms.button class="w-full" disabled>
Currently used
</x-forms.button>
@endif
@if (data_get($server, 'privateKey.uuid') !== $private_key->uuid)
<x-forms.button canGate="update" :canResource="$server" class="w-full" wire:click='setPrivateKey({{ $private_key->id }})'>
Use this key
</x-forms.button>
@else
<x-forms.button class="w-full" disabled>
Currently used
</x-forms.button>
@endif
</div>
</div>
@empty
<div>No private keys found. </div>
@ -42,4 +93,31 @@ class="box-without-bg justify-between dark:bg-coolgray-100 bg-white items-center
</div>
</div>
</div>
@script
<script>
window.copyPublicKeyToClipboard = (publicKey) => {
if (!publicKey) {
return;
}
if (!navigator.clipboard?.writeText) {
Livewire.dispatch('error', ['Failed to copy public key to clipboard.']);
return;
}
navigator.clipboard.writeText(publicKey).then(() => {
Livewire.dispatch('success', ['Public key copied to clipboard.']);
}).catch(() => {
Livewire.dispatch('error', ['Failed to copy public key to clipboard.']);
});
};
$wire.on('copyPublicKeyToClipboard', (event) => {
const publicKey = event?.detail?.publicKey ?? event?.publicKey;
window.copyPublicKeyToClipboard(publicKey);
});
</script>
@endscript
</div>

View file

@ -212,6 +212,11 @@ class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-warn
<span>Validating...</span>
</div>
@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)
<x-modal-confirmation title="Confirm Server Settings Change?" buttonTitle="Save"
submitAction="submit" :actions="[
@ -221,6 +226,296 @@ class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-warn
@else
<x-forms.button type="submit" canGate="update" :canResource="$server"
:disabled="$isValidating">Save</x-forms.button>
@if ($hasLinkableCloudProviders)
<div x-data="{ dropdownOpen: false }" class="relative w-fit" @click.outside="dropdownOpen = false">
<x-forms.button @click="dropdownOpen = !dropdownOpen" type="button" canGate="update"
:canResource="$server" :disabled="$isValidating">
Link Cloud Provider
<svg class="w-4 h-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
</svg>
</x-forms.button>
<div x-show="dropdownOpen" @click.away="dropdownOpen=false"
x-transition:enter="ease-out duration-200"
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
class="absolute top-0 z-50 mt-10 min-w-max" x-cloak>
<div
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
<div class="flex flex-col gap-1">
@if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
<x-modal-input title="Link to Hetzner Cloud" :wireIgnore="false">
<x-slot:content>
<div class="dropdown-item" @click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Hetzner
</div>
</x-slot:content>
<div class="space-y-4">
<p class="text-sm dark:text-neutral-400">
Link this server to a Hetzner Cloud instance to enable power controls and status monitoring.
</p>
<div class="w-full">
<x-forms.select wire:model.live="selectedHetznerTokenId"
label="Hetzner Token" canGate="update" :canResource="$server">
<option value="">Select a token...</option>
@foreach ($availableHetznerTokens as $token)
<option value="{{ $token->id }}">{{ $token->name }}</option>
@endforeach
</x-forms.select>
</div>
<div class="space-y-4">
<div class="flex items-end gap-2">
<div class="flex-1">
<x-forms.input wire:model="manualHetznerServerId"
label="Server ID" placeholder="e.g., 12345678"
helper="Enter the Hetzner Server ID from your Hetzner Cloud console"
canGate="update" :canResource="$server" />
</div>
<x-forms.button wire:click="searchHetznerServerById"
wire:loading.attr="disabled" canGate="update"
:canResource="$server" :disabled="! $selectedHetznerTokenId">
<span wire:loading.remove wire:target="searchHetznerServerById">Search</span>
<span wire:loading wire:target="searchHetznerServerById">Searching...</span>
</x-forms.button>
</div>
<div class="flex items-center gap-3 text-sm dark:text-neutral-500">
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
<span>OR</span>
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
</div>
<x-forms.button wire:click="searchHetznerServer"
wire:loading.attr="disabled" canGate="update"
:canResource="$server" :disabled="! $selectedHetznerTokenId"
class="w-full justify-center">
<span wire:loading.remove wire:target="searchHetznerServer">Search by IP</span>
<span wire:loading wire:target="searchHetznerServer">Searching...</span>
</x-forms.button>
</div>
@if ($hetznerSearchError)
<div class="p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
<p class="text-red-600 dark:text-red-400">{{ $hetznerSearchError }}</p>
</div>
@endif
@if ($hetznerNoMatchFound)
<div class="p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
<p class="text-yellow-600 dark:text-yellow-400">
@if ($manualHetznerServerId)
No Hetzner server found with ID: {{ $manualHetznerServerId }}
@else
No Hetzner server found matching IP: {{ $server->ip }}
@endif
</p>
<p class="mt-1 text-sm dark:text-neutral-400">
Try a different token, enter the Server ID manually, or verify the details are correct.
</p>
</div>
@endif
@if ($matchedHetznerServer)
<div class="p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
<h4 class="mb-2 font-semibold text-green-700 dark:text-green-400">Match Found!</h4>
<div class="grid gap-2 mb-4 text-sm lg:grid-cols-2">
<div><span class="font-medium">Name:</span> {{ $matchedHetznerServer['name'] }}</div>
<div><span class="font-medium">ID:</span> {{ $matchedHetznerServer['id'] }}</div>
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedHetznerServer['status']) }}</div>
<div><span class="font-medium">Type:</span> {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}</div>
</div>
<x-forms.button wire:click="linkToHetzner" isHighlighted canGate="update" :canResource="$server">
Link This Server
</x-forms.button>
</div>
@endif
</div>
</x-modal-input>
@endif
@if (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty())
<x-modal-input title="Link to DigitalOcean" :wireIgnore="false">
<x-slot:content>
<div class="dropdown-item" @click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
DigitalOcean
</div>
</x-slot:content>
<div class="space-y-4">
<p class="text-sm dark:text-neutral-400">
Link this server to a DigitalOcean droplet to enable power controls and status monitoring.
</p>
<div class="w-full">
<x-forms.select wire:model.live="selectedDigitalOceanTokenId"
label="DigitalOcean Token" canGate="update" :canResource="$server">
<option value="">Select a token...</option>
@foreach ($availableDigitalOceanTokens as $token)
<option value="{{ $token->id }}">{{ $token->name }}</option>
@endforeach
</x-forms.select>
</div>
<div class="space-y-4">
<div class="flex items-end gap-2">
<div class="flex-1">
<x-forms.input wire:model="manualDigitalOceanDropletId"
label="Droplet ID" placeholder="e.g., 123456789"
helper="Enter the Droplet ID from your DigitalOcean dashboard"
canGate="update" :canResource="$server" />
</div>
<x-forms.button wire:click="searchDigitalOceanDropletById"
wire:loading.attr="disabled" canGate="update"
:canResource="$server" :disabled="! $selectedDigitalOceanTokenId">
<span wire:loading.remove wire:target="searchDigitalOceanDropletById">Search</span>
<span wire:loading wire:target="searchDigitalOceanDropletById">Searching...</span>
</x-forms.button>
</div>
<div class="flex items-center gap-3 text-sm dark:text-neutral-500">
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
<span>OR</span>
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
</div>
<x-forms.button wire:click="searchDigitalOceanDroplet"
wire:loading.attr="disabled" canGate="update"
:canResource="$server" :disabled="! $selectedDigitalOceanTokenId"
class="w-full justify-center">
<span wire:loading.remove wire:target="searchDigitalOceanDroplet">Search by IP</span>
<span wire:loading wire:target="searchDigitalOceanDroplet">Searching...</span>
</x-forms.button>
</div>
@if ($digitalOceanSearchError)
<div class="p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
<p class="text-red-600 dark:text-red-400">{{ $digitalOceanSearchError }}</p>
</div>
@endif
@if ($digitalOceanNoMatchFound)
<div class="p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
<p class="text-yellow-600 dark:text-yellow-400">
@if ($manualDigitalOceanDropletId)
No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }}
@else
No DigitalOcean droplet found matching IP: {{ $server->ip }}
@endif
</p>
<p class="mt-1 text-sm dark:text-neutral-400">
Try a different token, enter the Droplet ID manually, or verify the details are correct.
</p>
</div>
@endif
@if ($matchedDigitalOceanDroplet)
<div class="p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
<h4 class="mb-2 font-semibold text-green-700 dark:text-green-400">Match Found!</h4>
<div class="grid gap-2 mb-4 text-sm lg:grid-cols-2">
<div><span class="font-medium">Name:</span> {{ $matchedDigitalOceanDroplet['name'] ?? 'Unknown' }}</div>
<div><span class="font-medium">ID:</span> {{ $matchedDigitalOceanDroplet['id'] }}</div>
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedDigitalOceanDroplet['status'] ?? 'unknown') }}</div>
<div><span class="font-medium">Size:</span> {{ data_get($matchedDigitalOceanDroplet, 'size.slug', 'Unknown') }}</div>
</div>
<x-forms.button wire:click="linkToDigitalOcean" isHighlighted canGate="update" :canResource="$server">
Link This Server
</x-forms.button>
</div>
@endif
</div>
</x-modal-input>
@endif
@if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
<x-modal-input title="Link to Vultr" :wireIgnore="false">
<x-slot:content>
<div class="dropdown-item" @click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Vultr
</div>
</x-slot:content>
<div class="space-y-4">
<p class="text-sm dark:text-neutral-400">
Link this server to a Vultr instance to enable power controls and status monitoring.
</p>
<div class="w-full">
<x-forms.select wire:model.live="selectedVultrTokenId"
label="Vultr Token" canGate="update" :canResource="$server">
<option value="">Select a token...</option>
@foreach ($availableVultrTokens as $token)
<option value="{{ $token->id }}">{{ $token->name }}</option>
@endforeach
</x-forms.select>
</div>
<div class="space-y-4">
<div class="flex items-end gap-2">
<div class="flex-1">
<x-forms.input wire:model="manualVultrInstanceId"
label="Instance ID" placeholder="e.g., 6d4b..."
helper="Enter the Vultr Instance ID from your Vultr dashboard"
canGate="update" :canResource="$server" />
</div>
<x-forms.button wire:click="searchVultrInstanceById"
wire:loading.attr="disabled" canGate="update"
:canResource="$server" :disabled="! $selectedVultrTokenId">
<span wire:loading.remove wire:target="searchVultrInstanceById">Search</span>
<span wire:loading wire:target="searchVultrInstanceById">Searching...</span>
</x-forms.button>
</div>
<div class="flex items-center gap-3 text-sm dark:text-neutral-500">
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
<span>OR</span>
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
</div>
<x-forms.button wire:click="searchVultrInstance"
wire:loading.attr="disabled" canGate="update"
:canResource="$server" :disabled="! $selectedVultrTokenId"
class="w-full justify-center">
<span wire:loading.remove wire:target="searchVultrInstance">Search by IP</span>
<span wire:loading wire:target="searchVultrInstance">Searching...</span>
</x-forms.button>
</div>
@if ($vultrSearchError)
<div class="p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
<p class="text-red-600 dark:text-red-400">{{ $vultrSearchError }}</p>
</div>
@endif
@if ($vultrNoMatchFound)
<div class="p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
<p class="text-yellow-600 dark:text-yellow-400">
@if ($manualVultrInstanceId)
No Vultr instance found with ID: {{ $manualVultrInstanceId }}
@else
No Vultr instance found matching IP: {{ $server->ip }}
@endif
</p>
<p class="mt-1 text-sm dark:text-neutral-400">
Try a different token, enter the Instance ID manually, or verify the details are correct.
</p>
</div>
@endif
@if ($matchedVultrInstance)
<div class="p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
<h4 class="mb-2 font-semibold text-green-700 dark:text-green-400">Match Found!</h4>
<div class="grid gap-2 mb-4 text-sm lg:grid-cols-2">
<div><span class="font-medium">Name:</span> {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}</div>
<div><span class="font-medium">ID:</span> {{ $matchedVultrInstance['id'] }}</div>
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}</div>
<div><span class="font-medium">Plan:</span> {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}</div>
</div>
<x-forms.button wire:click="linkToVultr" isHighlighted canGate="update" :canResource="$server">
Link This Server
</x-forms.button>
</div>
@endif
</div>
</x-modal-input>
@endif
</div>
</div>
</div>
</div>
@endif
@if ($server->isFunctional())
<x-slide-over closeWithX fullScreen>
<x-slot:title>Validate & configure</x-slot:title>
@ -482,228 +777,6 @@ class="dark:hover:fill-white fill-black dark:fill-warning">
@endif
</div>
@endif
@if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
<div class="pt-6">
<h3>Link to Hetzner Cloud</h3>
<p class="pb-4 text-sm dark:text-neutral-400">
Link this server to a Hetzner Cloud instance to enable power controls and status monitoring.
</p>
<div class="flex flex-wrap gap-4 items-end">
<div class="w-72">
<x-forms.select wire:model="selectedHetznerTokenId" label="Hetzner Token"
canGate="update" :canResource="$server">
<option value="">Select a token...</option>
@foreach ($availableHetznerTokens as $token)
<option value="{{ $token->id }}">{{ $token->name }}</option>
@endforeach
</x-forms.select>
</div>
<div class="w-48">
<x-forms.input wire:model="manualHetznerServerId"
label="Server ID"
placeholder="e.g., 12345678"
helper="Enter the Hetzner Server ID from your Hetzner Cloud console"
canGate="update" :canResource="$server" />
</div>
<x-forms.button wire:click="searchHetznerServerById"
wire:loading.attr="disabled"
canGate="update" :canResource="$server">
<span wire:loading.remove wire:target="searchHetznerServerById">Search by ID</span>
<span wire:loading wire:target="searchHetznerServerById">Searching...</span>
</x-forms.button>
<div class="self-end pb-2 text-sm dark:text-neutral-500">OR</div>
<x-forms.button wire:click="searchHetznerServer"
wire:loading.attr="disabled"
canGate="update" :canResource="$server">
<span wire:loading.remove wire:target="searchHetznerServer">Search by IP</span>
<span wire:loading wire:target="searchHetznerServer">Searching...</span>
</x-forms.button>
</div>
@if ($hetznerSearchError)
<div class="mt-4 p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
<p class="text-red-600 dark:text-red-400">{{ $hetznerSearchError }}</p>
</div>
@endif
@if ($hetznerNoMatchFound)
<div class="mt-4 p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
<p class="text-yellow-600 dark:text-yellow-400">
@if ($manualHetznerServerId)
No Hetzner server found with ID: {{ $manualHetznerServerId }}
@else
No Hetzner server found matching IP: {{ $server->ip }}
@endif
</p>
<p class="text-sm dark:text-neutral-400 mt-1">
Try a different token, enter the Server ID manually, or verify the details are correct.
</p>
</div>
@endif
@if ($matchedHetznerServer)
<div class="mt-4 p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
<h4 class="font-semibold text-green-700 dark:text-green-400 mb-2">Match Found!</h4>
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
<div><span class="font-medium">Name:</span> {{ $matchedHetznerServer['name'] }}</div>
<div><span class="font-medium">ID:</span> {{ $matchedHetznerServer['id'] }}</div>
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedHetznerServer['status']) }}</div>
<div><span class="font-medium">Type:</span> {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}</div>
</div>
<x-forms.button wire:click="linkToHetzner" isHighlighted canGate="update" :canResource="$server">
Link This Server
</x-forms.button>
</div>
@endif
</div>
@endif
@if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
<div class="pt-6">
<h3>Link to Vultr</h3>
<p class="pb-4 text-sm dark:text-neutral-400">
Link this server to a Vultr instance to enable power controls and status monitoring.
</p>
<div class="flex flex-wrap gap-4 items-end">
<div class="w-72">
<x-forms.select wire:model="selectedVultrTokenId" label="Vultr Token"
canGate="update" :canResource="$server">
<option value="">Select a token...</option>
@foreach ($availableVultrTokens as $token)
<option value="{{ $token->id }}">{{ $token->name }}</option>
@endforeach
</x-forms.select>
</div>
<div class="w-72">
<x-forms.input wire:model="manualVultrInstanceId"
label="Instance ID"
placeholder="e.g., 6d4b..."
helper="Enter the Vultr Instance ID from your Vultr dashboard"
canGate="update" :canResource="$server" />
</div>
<x-forms.button wire:click="searchVultrInstanceById"
wire:loading.attr="disabled"
canGate="update" :canResource="$server">
<span wire:loading.remove wire:target="searchVultrInstanceById">Search by ID</span>
<span wire:loading wire:target="searchVultrInstanceById">Searching...</span>
</x-forms.button>
<div class="self-end pb-2 text-sm dark:text-neutral-500">OR</div>
<x-forms.button wire:click="searchVultrInstance"
wire:loading.attr="disabled"
canGate="update" :canResource="$server">
<span wire:loading.remove wire:target="searchVultrInstance">Search by IP</span>
<span wire:loading wire:target="searchVultrInstance">Searching...</span>
</x-forms.button>
</div>
@if ($vultrSearchError)
<div class="mt-4 p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
<p class="text-red-600 dark:text-red-400">{{ $vultrSearchError }}</p>
</div>
@endif
@if ($vultrNoMatchFound)
<div class="mt-4 p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
<p class="text-yellow-600 dark:text-yellow-400">
@if ($manualVultrInstanceId)
No Vultr instance found with ID: {{ $manualVultrInstanceId }}
@else
No Vultr instance found matching IP: {{ $server->ip }}
@endif
</p>
<p class="text-sm dark:text-neutral-400 mt-1">
Try a different token, enter the Instance ID manually, or verify the details are correct.
</p>
</div>
@endif
@if ($matchedVultrInstance)
<div class="mt-4 p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
<h4 class="font-semibold text-green-700 dark:text-green-400 mb-2">Match Found!</h4>
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
<div><span class="font-medium">Name:</span> {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}</div>
<div><span class="font-medium">ID:</span> {{ $matchedVultrInstance['id'] }}</div>
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}</div>
<div><span class="font-medium">Plan:</span> {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}</div>
</div>
<x-forms.button wire:click="linkToVultr" isHighlighted canGate="update" :canResource="$server">
Link This Server
</x-forms.button>
</div>
@endif
</div>
@endif
@if (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty())
<div class="pt-6">
<h3>Link to DigitalOcean</h3>
<p class="pb-4 text-sm dark:text-neutral-400">
Link this server to a DigitalOcean droplet to enable power controls and status monitoring.
</p>
<div class="flex flex-wrap gap-4 items-end">
<div class="w-72">
<x-forms.select wire:model="selectedDigitalOceanTokenId" label="DigitalOcean Token"
canGate="update" :canResource="$server">
<option value="">Select a token...</option>
@foreach ($availableDigitalOceanTokens as $token)
<option value="{{ $token->id }}">{{ $token->name }}</option>
@endforeach
</x-forms.select>
</div>
<div class="w-72">
<x-forms.input wire:model="manualDigitalOceanDropletId" label="Droplet ID"
placeholder="e.g., 123456789"
helper="Enter the Droplet ID from your DigitalOcean dashboard"
canGate="update" :canResource="$server" />
</div>
<x-forms.button wire:click="searchDigitalOceanDropletById" wire:loading.attr="disabled"
canGate="update" :canResource="$server">
<span wire:loading.remove wire:target="searchDigitalOceanDropletById">Search by ID</span>
<span wire:loading wire:target="searchDigitalOceanDropletById">Searching...</span>
</x-forms.button>
<div class="self-end pb-2 text-sm dark:text-neutral-500">OR</div>
<x-forms.button wire:click="searchDigitalOceanDroplet" wire:loading.attr="disabled"
canGate="update" :canResource="$server">
<span wire:loading.remove wire:target="searchDigitalOceanDroplet">Search by IP</span>
<span wire:loading wire:target="searchDigitalOceanDroplet">Searching...</span>
</x-forms.button>
</div>
@if ($digitalOceanSearchError)
<div class="mt-4 p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
<p class="text-red-600 dark:text-red-400">{{ $digitalOceanSearchError }}</p>
</div>
@endif
@if ($digitalOceanNoMatchFound)
<div class="mt-4 p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
<p class="text-yellow-600 dark:text-yellow-400">
@if ($manualDigitalOceanDropletId)
No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }}
@else
No DigitalOcean droplet found matching IP: {{ $server->ip }}
@endif
</p>
</div>
@endif
@if ($matchedDigitalOceanDroplet)
<div class="mt-4 p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
<h4 class="font-semibold text-green-700 dark:text-green-400 mb-2">Match Found!</h4>
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
<div><span class="font-medium">Name:</span> {{ $matchedDigitalOceanDroplet['name'] ?? 'Unknown' }}</div>
<div><span class="font-medium">ID:</span> {{ $matchedDigitalOceanDroplet['id'] }}</div>
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedDigitalOceanDroplet['status'] ?? 'unknown') }}</div>
<div><span class="font-medium">Size:</span> {{ data_get($matchedDigitalOceanDroplet, 'size.slug', 'Unknown') }}</div>
</div>
<x-forms.button wire:click="linkToDigitalOcean" isHighlighted canGate="update" :canResource="$server">
Link This Server
</x-forms.button>
</div>
@endif
</div>
@endif
</div>
</div>
</div>

View file

@ -0,0 +1,192 @@
<?php
use App\Livewire\Server\Show;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.driver' => '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');
});

View file

@ -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('<x-forms.button canGate="update" :canResource="$private_key" type="submit">')
->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();
});

View file

@ -0,0 +1,100 @@
<?php
use App\Livewire\Server\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;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
if (! InstanceSettings::query()->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.');
});