From ac3af8a882de907dd8ac172c13554b0fb17a17e9 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Oct 2025 12:17:05 +0200
Subject: [PATCH] feat: add support for selecting additional SSH keys from
Hetzner in server creation form
---
app/Livewire/Server/New/ByHetzner.php | 26 ++-
app/View/Components/Forms/Datalist.php | 26 ++-
.../views/components/forms/datalist.blade.php | 209 +++++++++++++++---
.../livewire/server/new/by-hetzner.blade.php | 16 ++
tests/Feature/HetznerServerCreationTest.php | 49 ++++
tests/Unit/DatalistComponentTest.php | 68 ++++++
tests/Unit/HetznerSshKeysTest.php | 53 +++++
7 files changed, 413 insertions(+), 34 deletions(-)
create mode 100644 tests/Feature/HetznerServerCreationTest.php
create mode 100644 tests/Unit/DatalistComponentTest.php
create mode 100644 tests/Unit/HetznerSshKeysTest.php
diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php
index 6f7e915c9..e344524ea 100644
--- a/app/Livewire/Server/New/ByHetzner.php
+++ b/app/Livewire/Server/New/ByHetzner.php
@@ -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);
diff --git a/app/View/Components/Forms/Datalist.php b/app/View/Components/Forms/Datalist.php
index 25643753d..33e264e37 100644
--- a/app/View/Components/Forms/Datalist.php
+++ b/app/View/Components/Forms/Datalist.php
@@ -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');
}
}
diff --git a/resources/views/components/forms/datalist.blade.php b/resources/views/components/forms/datalist.blade.php
index c9710b728..8ce19773e 100644
--- a/resources/views/components/forms/datalist.blade.php
+++ b/resources/views/components/forms/datalist.blade.php
@@ -1,6 +1,6 @@
-
+ @endif
+
@error($id)
{{ $message }}
@enderror
- {{-- --}}
diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php
index e95c85887..b8191593d 100644
--- a/resources/views/livewire/server/new/by-hetzner.blade.php
+++ b/resources/views/livewire/server/new/by-hetzner.blade.php
@@ -99,6 +99,22 @@
+
+
+
+
diff --git a/tests/Feature/HetznerServerCreationTest.php b/tests/Feature/HetznerServerCreationTest.php
new file mode 100644
index 000000000..815462ffa
--- /dev/null
+++ b/tests/Feature/HetznerServerCreationTest.php
@@ -0,0 +1,49 @@
+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);
+});
diff --git a/tests/Unit/DatalistComponentTest.php b/tests/Unit/DatalistComponentTest.php
new file mode 100644
index 000000000..12699c30a
--- /dev/null
+++ b/tests/Unit/DatalistComponentTest.php
@@ -0,0 +1,68 @@
+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();
+});
diff --git a/tests/Unit/HetznerSshKeysTest.php b/tests/Unit/HetznerSshKeysTest.php
new file mode 100644
index 000000000..06c6b06e6
--- /dev/null
+++ b/tests/Unit/HetznerSshKeysTest.php
@@ -0,0 +1,53 @@
+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();
+ }
+});