feat: add support for selecting additional SSH keys from Hetzner in server creation form
This commit is contained in:
parent
ee211526ea
commit
ac3af8a882
7 changed files with 413 additions and 34 deletions
|
|
@ -42,12 +42,16 @@ class ByHetzner extends Component
|
|||
|
||||
public array $serverTypes = [];
|
||||
|
||||
public array $hetznerSshKeys = [];
|
||||
|
||||
public ?string $selected_location = null;
|
||||
|
||||
public ?int $selected_image = null;
|
||||
|
||||
public ?string $selected_server_type = null;
|
||||
|
||||
public array $selectedHetznerSshKeyIds = [];
|
||||
|
||||
public string $server_name = '';
|
||||
|
||||
public ?int $private_key_id = null;
|
||||
|
|
@ -110,6 +114,8 @@ protected function rules(): array
|
|||
'selected_image' => 'required|integer',
|
||||
'selected_server_type' => 'required|string',
|
||||
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
|
||||
'selectedHetznerSshKeyIds' => 'nullable|array',
|
||||
'selectedHetznerSshKeyIds.*' => 'integer',
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -224,6 +230,14 @@ private function loadHetznerData(string $token)
|
|||
'debian_images' => collect($this->images)->filter(fn ($img) => str_contains($img['name'] ?? '', 'debian'))->values(),
|
||||
]);
|
||||
|
||||
// Load SSH keys from Hetzner
|
||||
$this->hetznerSshKeys = $hetznerService->getSshKeys();
|
||||
|
||||
ray('Hetzner SSH Keys', [
|
||||
'total_count' => count($this->hetznerSshKeys),
|
||||
'keys' => $this->hetznerSshKeys,
|
||||
]);
|
||||
|
||||
$this->loading_data = false;
|
||||
} catch (\Throwable $e) {
|
||||
$this->loading_data = false;
|
||||
|
|
@ -365,6 +379,16 @@ private function createHetznerServer(string $token): array
|
|||
// Normalize server name to lowercase for RFC 1123 compliance
|
||||
$normalizedServerName = strtolower(trim($this->server_name));
|
||||
|
||||
// Prepare SSH keys array: Coolify key + user-selected Hetzner keys
|
||||
$sshKeys = array_merge(
|
||||
[$sshKeyId], // Coolify key (always included)
|
||||
$this->selectedHetznerSshKeyIds // User-selected Hetzner keys
|
||||
);
|
||||
|
||||
// Remove duplicates in case the Coolify key was also selected
|
||||
$sshKeys = array_unique($sshKeys);
|
||||
$sshKeys = array_values($sshKeys); // Re-index array
|
||||
|
||||
// Prepare server creation parameters
|
||||
$params = [
|
||||
'name' => $normalizedServerName,
|
||||
|
|
@ -372,7 +396,7 @@ private function createHetznerServer(string $token): array
|
|||
'image' => $this->selected_image,
|
||||
'location' => $this->selected_location,
|
||||
'start_after_create' => true,
|
||||
'ssh_keys' => [$sshKeyId],
|
||||
'ssh_keys' => $sshKeys,
|
||||
];
|
||||
|
||||
ray('Server creation parameters', $params);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\View\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
|
|
@ -19,9 +19,27 @@ public function __construct(
|
|||
public ?string $label = null,
|
||||
public ?string $helper = null,
|
||||
public bool $required = false,
|
||||
public string $defaultClass = 'input'
|
||||
public bool $disabled = false,
|
||||
public bool $readonly = false,
|
||||
public bool $multiple = false,
|
||||
public string|bool $instantSave = false,
|
||||
public ?string $value = null,
|
||||
public ?string $placeholder = null,
|
||||
public bool $autofocus = false,
|
||||
public string $defaultClass = 'input',
|
||||
public ?string $canGate = null,
|
||||
public mixed $canResource = null,
|
||||
public bool $autoDisable = true,
|
||||
) {
|
||||
//
|
||||
// Handle authorization-based disabling
|
||||
if ($this->canGate && $this->canResource && $this->autoDisable) {
|
||||
$hasPermission = Gate::allows($this->canGate, $this->canResource);
|
||||
|
||||
if (! $hasPermission) {
|
||||
$this->disabled = true;
|
||||
$this->instantSave = false; // Disable instant save for unauthorized users
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -36,8 +54,6 @@ public function render(): View|Closure|string
|
|||
$this->name = $this->id;
|
||||
}
|
||||
|
||||
$this->label = Str::title($this->label);
|
||||
|
||||
return view('components.forms.datalist');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="w-full">
|
||||
<label>
|
||||
@if ($label)
|
||||
@if ($label)
|
||||
<label class="flex gap-1 items-center mb-1 text-sm font-medium {{ $disabled ? 'text-neutral-600' : '' }}">
|
||||
{{ $label }}
|
||||
@if ($required)
|
||||
<x-highlighted text="*" />
|
||||
|
|
@ -8,37 +8,190 @@
|
|||
@if ($helper)
|
||||
<x-helper :helper="$helper" />
|
||||
@endif
|
||||
@endif
|
||||
<input list={{ $id }} {{ $attributes->merge(['class' => $defaultClass]) }} @required($required)
|
||||
wire:dirty.class.remove='dark:text-white' wire:dirty.class="text-black bg-warning" wire:loading.attr="disabled"
|
||||
name={{ $id }}
|
||||
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} @else wire:model={{ $id }} @endif
|
||||
@if ($attributes->whereStartsWith('onUpdate')->first()) wire:change={{ $attributes->whereStartsWith('onUpdate')->first() }} wire:keydown.enter={{ $attributes->whereStartsWith('onUpdate')->first() }} wire:blur={{ $attributes->whereStartsWith('onUpdate')->first() }} @else wire:change={{ $id }} wire:blur={{ $id }} wire:keydown.enter={{ $id }} @endif>
|
||||
<datalist id={{ $id }}>
|
||||
</label>
|
||||
@endif
|
||||
|
||||
@if ($multiple)
|
||||
{{-- Multiple Selection Mode with Alpine.js --}}
|
||||
<div x-data="{
|
||||
open: false,
|
||||
search: '',
|
||||
selected: @entangle($id).live,
|
||||
options: [],
|
||||
filteredOptions: [],
|
||||
|
||||
init() {
|
||||
this.options = Array.from(this.$refs.datalist.querySelectorAll('option')).map(opt => {
|
||||
// Try to parse as integer, fallback to string
|
||||
let value = opt.value;
|
||||
const intValue = parseInt(value, 10);
|
||||
if (!isNaN(intValue) && intValue.toString() === value) {
|
||||
value = intValue;
|
||||
}
|
||||
return {
|
||||
value: value,
|
||||
text: opt.textContent.trim()
|
||||
};
|
||||
});
|
||||
this.filteredOptions = this.options;
|
||||
// Ensure selected is always an array
|
||||
if (!Array.isArray(this.selected)) {
|
||||
this.selected = [];
|
||||
}
|
||||
},
|
||||
|
||||
filterOptions() {
|
||||
if (!this.search) {
|
||||
this.filteredOptions = this.options;
|
||||
return;
|
||||
}
|
||||
const searchLower = this.search.toLowerCase();
|
||||
this.filteredOptions = this.options.filter(opt =>
|
||||
opt.text.toLowerCase().includes(searchLower)
|
||||
);
|
||||
},
|
||||
|
||||
toggleOption(value) {
|
||||
// Ensure selected is an array
|
||||
if (!Array.isArray(this.selected)) {
|
||||
this.selected = [];
|
||||
}
|
||||
const index = this.selected.indexOf(value);
|
||||
if (index > -1) {
|
||||
this.selected.splice(index, 1);
|
||||
} else {
|
||||
this.selected.push(value);
|
||||
}
|
||||
this.search = '';
|
||||
this.filterOptions();
|
||||
},
|
||||
|
||||
removeOption(value) {
|
||||
// Ensure selected is an array
|
||||
if (!Array.isArray(this.selected)) {
|
||||
this.selected = [];
|
||||
return;
|
||||
}
|
||||
const index = this.selected.indexOf(value);
|
||||
if (index > -1) {
|
||||
this.selected.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
isSelected(value) {
|
||||
// Ensure selected is an array
|
||||
if (!Array.isArray(this.selected)) {
|
||||
return false;
|
||||
}
|
||||
return this.selected.includes(value);
|
||||
},
|
||||
|
||||
getSelectedText(value) {
|
||||
const option = this.options.find(opt => opt.value == value);
|
||||
return option ? option.text : value;
|
||||
}
|
||||
}"
|
||||
@click.outside="open = false"
|
||||
class="relative">
|
||||
|
||||
{{-- Selected Items Display --}}
|
||||
<div class="grid grid-cols-2 gap-2 mb-2 max-h-32 overflow-y-auto" x-show="Array.isArray(selected) && selected.length > 0">
|
||||
<template x-for="value in selected" :key="value">
|
||||
<span class="inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-coolgray-200 dark:bg-coolgray-700 rounded">
|
||||
<span x-text="getSelectedText(value)" class="truncate flex-1"></span>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeOption(value)"
|
||||
:disabled="{{ $disabled ? 'true' : 'false' }}"
|
||||
class="text-lg leading-none hover:text-red-500 {{ $disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer' }}"
|
||||
aria-label="Remove">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- Search Input --}}
|
||||
<input
|
||||
type="text"
|
||||
x-model="search"
|
||||
@input="filterOptions()"
|
||||
@focus="open = true"
|
||||
@keydown.escape="open = false"
|
||||
:placeholder="{{ json_encode($placeholder ?: 'Search...') }}"
|
||||
{{ $attributes->merge(['class' => $defaultClass]) }}
|
||||
@required($required)
|
||||
@readonly($readonly)
|
||||
@disabled($disabled)
|
||||
wire:dirty.class="dark:ring-warning ring-warning"
|
||||
wire:loading.attr="disabled"
|
||||
@if ($autofocus) x-ref="autofocusInput" @endif
|
||||
>
|
||||
|
||||
{{-- Dropdown Options --}}
|
||||
<div
|
||||
x-show="open && !{{ $disabled ? 'true' : 'false' }}"
|
||||
x-transition
|
||||
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg max-h-60 overflow-auto">
|
||||
|
||||
<template x-if="filteredOptions.length === 0">
|
||||
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
No options found
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-for="option in filteredOptions" :key="option.value">
|
||||
<div
|
||||
@click="toggleOption(option.value)"
|
||||
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 flex items-center gap-3"
|
||||
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': isSelected(option.value) }">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isSelected(option.value)"
|
||||
class="w-4 h-4 rounded border-neutral-300 dark:border-neutral-600 bg-white dark:bg-coolgray-100 text-black dark:text-white checked:bg-white dark:checked:bg-coolgray-100 focus:ring-coollabs dark:focus:ring-warning pointer-events-none"
|
||||
tabindex="-1">
|
||||
<span class="text-sm flex-1" x-text="option.text"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- Hidden datalist for options --}}
|
||||
<datalist x-ref="datalist" style="display: none;">
|
||||
{{ $slot }}
|
||||
</datalist>
|
||||
</div>
|
||||
@else
|
||||
{{-- Single Selection Mode (Standard HTML5 Datalist) --}}
|
||||
<input
|
||||
list="{{ $id }}"
|
||||
{{ $attributes->merge(['class' => $defaultClass]) }}
|
||||
@required($required)
|
||||
@readonly($readonly)
|
||||
@disabled($disabled)
|
||||
wire:dirty.class="dark:ring-warning ring-warning"
|
||||
wire:loading.attr="disabled"
|
||||
name="{{ $id }}"
|
||||
@if ($value) value="{{ $value }}" @endif
|
||||
@if ($placeholder) placeholder="{{ $placeholder }}" @endif
|
||||
@if ($attributes->whereStartsWith('wire:model')->first())
|
||||
{{ $attributes->whereStartsWith('wire:model')->first() }}
|
||||
@else
|
||||
wire:model="{{ $id }}"
|
||||
@endif
|
||||
@if ($instantSave)
|
||||
wire:change="{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}"
|
||||
wire:blur="{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}"
|
||||
@endif
|
||||
@if ($autofocus) x-ref="autofocusInput" @endif
|
||||
>
|
||||
<datalist id="{{ $id }}">
|
||||
{{ $slot }}
|
||||
</datalist>
|
||||
</label>
|
||||
@endif
|
||||
|
||||
@error($id)
|
||||
<label class="label">
|
||||
<span class="text-red-500 label-text-alt">{{ $message }}</span>
|
||||
</label>
|
||||
@enderror
|
||||
{{-- <script>
|
||||
const input = document.querySelector(`input[list={{ $id }}]`);
|
||||
input.addEventListener('focus', function(e) {
|
||||
const input = e.target.value;
|
||||
const datalist = document.getElementById('{{ $id }}');
|
||||
if (datalist.options) {
|
||||
for (let option of datalist.options) {
|
||||
// change background color to red on all options
|
||||
option.style.display = "none";
|
||||
if (option.value.includes(input)) {
|
||||
option.style.display = "block";
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
});
|
||||
</script> --}}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -99,6 +99,22 @@
|
|||
</x-forms.select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-forms.datalist
|
||||
label="Additional SSH Keys (from Hetzner)"
|
||||
id="selectedHetznerSshKeyIds"
|
||||
helper="Select existing SSH keys from your Hetzner account to add to this server. The Coolify SSH key will be automatically added."
|
||||
:multiple="true"
|
||||
:disabled="count($hetznerSshKeys) === 0"
|
||||
:placeholder="count($hetznerSshKeys) > 0 ? 'Search and select SSH keys...' : 'No SSH keys found in Hetzner account'">
|
||||
@foreach ($hetznerSshKeys as $sshKey)
|
||||
<option value="{{ $sshKey['id'] }}">
|
||||
{{ $sshKey['name'] }} - {{ substr($sshKey['fingerprint'], 0, 20) }}...
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-forms.select label="Private Key" id="private_key_id" required>
|
||||
<option value="">Select a private key...</option>
|
||||
|
|
|
|||
49
tests/Feature/HetznerServerCreationTest.php
Normal file
49
tests/Feature/HetznerServerCreationTest.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
// Note: Full Livewire integration tests require database setup
|
||||
// These tests verify the SSH key merging logic works correctly
|
||||
|
||||
it('validates SSH key array merging logic with Coolify key', function () {
|
||||
$coolifyKeyId = 123;
|
||||
$selectedHetznerKeys = [];
|
||||
|
||||
$sshKeys = array_merge(
|
||||
[$coolifyKeyId],
|
||||
$selectedHetznerKeys
|
||||
);
|
||||
$sshKeys = array_unique($sshKeys);
|
||||
$sshKeys = array_values($sshKeys);
|
||||
|
||||
expect($sshKeys)->toBe([123])
|
||||
->and(count($sshKeys))->toBe(1);
|
||||
});
|
||||
|
||||
it('validates SSH key array merging with additional Hetzner keys', function () {
|
||||
$coolifyKeyId = 123;
|
||||
$selectedHetznerKeys = [456, 789];
|
||||
|
||||
$sshKeys = array_merge(
|
||||
[$coolifyKeyId],
|
||||
$selectedHetznerKeys
|
||||
);
|
||||
$sshKeys = array_unique($sshKeys);
|
||||
$sshKeys = array_values($sshKeys);
|
||||
|
||||
expect($sshKeys)->toBe([123, 456, 789])
|
||||
->and(count($sshKeys))->toBe(3);
|
||||
});
|
||||
|
||||
it('validates deduplication when Coolify key is also in selected keys', function () {
|
||||
$coolifyKeyId = 123;
|
||||
$selectedHetznerKeys = [123, 456, 789];
|
||||
|
||||
$sshKeys = array_merge(
|
||||
[$coolifyKeyId],
|
||||
$selectedHetznerKeys
|
||||
);
|
||||
$sshKeys = array_unique($sshKeys);
|
||||
$sshKeys = array_values($sshKeys);
|
||||
|
||||
expect($sshKeys)->toBe([123, 456, 789])
|
||||
->and(count($sshKeys))->toBe(3);
|
||||
});
|
||||
68
tests/Unit/DatalistComponentTest.php
Normal file
68
tests/Unit/DatalistComponentTest.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
use App\View\Components\Forms\Datalist;
|
||||
|
||||
it('renders with default properties', function () {
|
||||
$component = new Datalist;
|
||||
|
||||
expect($component->required)->toBeFalse()
|
||||
->and($component->disabled)->toBeFalse()
|
||||
->and($component->readonly)->toBeFalse()
|
||||
->and($component->multiple)->toBeFalse()
|
||||
->and($component->instantSave)->toBeFalse()
|
||||
->and($component->defaultClass)->toBe('input');
|
||||
});
|
||||
|
||||
it('uses provided id', function () {
|
||||
$component = new Datalist(id: 'test-datalist');
|
||||
|
||||
expect($component->id)->toBe('test-datalist');
|
||||
});
|
||||
|
||||
it('accepts multiple selection mode', function () {
|
||||
$component = new Datalist(multiple: true);
|
||||
|
||||
expect($component->multiple)->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts instantSave parameter', function () {
|
||||
$component = new Datalist(instantSave: 'customSave');
|
||||
|
||||
expect($component->instantSave)->toBe('customSave');
|
||||
});
|
||||
|
||||
it('accepts placeholder', function () {
|
||||
$component = new Datalist(placeholder: 'Select an option...');
|
||||
|
||||
expect($component->placeholder)->toBe('Select an option...');
|
||||
});
|
||||
|
||||
it('accepts autofocus', function () {
|
||||
$component = new Datalist(autofocus: true);
|
||||
|
||||
expect($component->autofocus)->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts disabled state', function () {
|
||||
$component = new Datalist(disabled: true);
|
||||
|
||||
expect($component->disabled)->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts readonly state', function () {
|
||||
$component = new Datalist(readonly: true);
|
||||
|
||||
expect($component->readonly)->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts authorization properties', function () {
|
||||
$component = new Datalist(
|
||||
canGate: 'update',
|
||||
canResource: 'resource',
|
||||
autoDisable: false
|
||||
);
|
||||
|
||||
expect($component->canGate)->toBe('update')
|
||||
->and($component->canResource)->toBe('resource')
|
||||
->and($component->autoDisable)->toBeFalse();
|
||||
});
|
||||
53
tests/Unit/HetznerSshKeysTest.php
Normal file
53
tests/Unit/HetznerSshKeysTest.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
it('merges Coolify key with selected Hetzner keys', function () {
|
||||
$coolifyKeyId = 123;
|
||||
$selectedHetznerKeys = [456, 789];
|
||||
|
||||
// Simulate the merge logic from createHetznerServer
|
||||
$sshKeys = array_merge(
|
||||
[$coolifyKeyId],
|
||||
$selectedHetznerKeys
|
||||
);
|
||||
|
||||
expect($sshKeys)->toBe([123, 456, 789])
|
||||
->and(count($sshKeys))->toBe(3);
|
||||
});
|
||||
|
||||
it('removes duplicate SSH key IDs', function () {
|
||||
$coolifyKeyId = 123;
|
||||
$selectedHetznerKeys = [123, 456, 789]; // User also selected Coolify key
|
||||
|
||||
// Simulate the merge and deduplication logic
|
||||
$sshKeys = array_merge(
|
||||
[$coolifyKeyId],
|
||||
$selectedHetznerKeys
|
||||
);
|
||||
$sshKeys = array_unique($sshKeys);
|
||||
$sshKeys = array_values($sshKeys);
|
||||
|
||||
expect($sshKeys)->toBe([123, 456, 789])
|
||||
->and(count($sshKeys))->toBe(3);
|
||||
});
|
||||
|
||||
it('works with no selected Hetzner keys', function () {
|
||||
$coolifyKeyId = 123;
|
||||
$selectedHetznerKeys = [];
|
||||
|
||||
// Simulate the merge logic
|
||||
$sshKeys = array_merge(
|
||||
[$coolifyKeyId],
|
||||
$selectedHetznerKeys
|
||||
);
|
||||
|
||||
expect($sshKeys)->toBe([123])
|
||||
->and(count($sshKeys))->toBe(1);
|
||||
});
|
||||
|
||||
it('validates SSH key IDs are integers', function () {
|
||||
$selectedHetznerKeys = [456, 789, 1011];
|
||||
|
||||
foreach ($selectedHetznerKeys as $keyId) {
|
||||
expect($keyId)->toBeInt();
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue