From e200d881f55d03ccf371780987839e5dafcb17e1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:58:27 +0200 Subject: [PATCH] feat(security): add editable cloud credential pages Add dedicated show/edit pages for cloud provider tokens and cloud-init scripts, including descriptions and UUID routes. Generate private keys directly from the index and surface cloud provider API loading errors in server creation flows. --- .../Security/CloudInitScript/Show.php | 97 ++++++++++ app/Livewire/Security/CloudInitScripts.php | 7 + .../Security/CloudProviderToken/Show.php | 177 ++++++++++++++++++ .../Security/CloudProviderTokenForm.php | 8 +- app/Livewire/Security/PrivateKey/Create.php | 24 --- app/Livewire/Security/PrivateKey/Index.php | 25 +++ app/Livewire/Server/New/ByDigitalOcean.php | 18 +- app/Livewire/Server/New/ByHetzner.php | 18 +- app/Livewire/Server/New/ByVultr.php | 18 +- app/Models/CloudInitScript.php | 4 +- app/Models/CloudProviderToken.php | 1 + app/Models/PrivateKey.php | 2 +- ...ription_to_cloud_provider_tokens_table.php | 28 +++ ...4_add_uuid_to_cloud_init_scripts_table.php | 33 ++++ .../security/cloud-init-script-form.blade.php | 7 +- .../security/cloud-init-script/show.blade.php | 28 +++ .../security/cloud-init-scripts.blade.php | 46 ++--- .../cloud-provider-token-form.blade.php | 17 +- .../cloud-provider-token/show.blade.php | 39 ++++ .../security/cloud-provider-tokens.blade.php | 113 +++++++---- .../security/private-key/create.blade.php | 5 - .../security/private-key/index.blade.php | 54 +++++- .../security/private-key/show.blade.php | 16 +- .../cloud-provider-token/show.blade.php | 7 +- .../server/new/by-digital-ocean.blade.php | 16 ++ .../livewire/server/new/by-hetzner.blade.php | 16 ++ .../livewire/server/new/by-vultr.blade.php | 16 ++ routes/web.php | 4 + .../SecurityPageAuthorizationTest.php | 6 +- tests/Feature/CloudProviderTokenShowTest.php | 48 +++++ .../Feature/Security/CloudInitScriptsTest.php | 122 ++++++++++++ .../Security/CloudProviderTokenFormTest.php | 35 ++++ .../Security/CloudProviderTokenShowTest.php | 106 +++++++++++ .../Security/PrivateKeyDropdownTest.php | 97 ++++++++++ .../Server/NewServerProviderTokenUrlTest.php | 27 +++ tests/Unit/CloudProviderTokenFormViewTest.php | 37 ++++ 36 files changed, 1196 insertions(+), 126 deletions(-) create mode 100644 app/Livewire/Security/CloudInitScript/Show.php create mode 100644 app/Livewire/Security/CloudProviderToken/Show.php create mode 100644 database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php create mode 100644 database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php create mode 100644 resources/views/livewire/security/cloud-init-script/show.blade.php create mode 100644 resources/views/livewire/security/cloud-provider-token/show.blade.php create mode 100644 tests/Feature/CloudProviderTokenShowTest.php create mode 100644 tests/Feature/Security/CloudInitScriptsTest.php create mode 100644 tests/Feature/Security/CloudProviderTokenShowTest.php create mode 100644 tests/Feature/Security/PrivateKeyDropdownTest.php diff --git a/app/Livewire/Security/CloudInitScript/Show.php b/app/Livewire/Security/CloudInitScript/Show.php new file mode 100644 index 000000000..6e2a3937d --- /dev/null +++ b/app/Livewire/Security/CloudInitScript/Show.php @@ -0,0 +1,97 @@ + 'required|string|max:255', + 'script' => ['required', 'string', new ValidCloudInitYaml], + ]; + } + + protected function messages(): array + { + return [ + 'name.required' => 'Script name is required.', + 'name.max' => 'Script name cannot exceed 255 characters.', + 'script.required' => 'Cloud-init script content is required.', + ]; + } + + public function mount(string $cloud_init_script_uuid): void + { + try { + $this->cloudInitScript = CloudInitScript::ownedByCurrentTeam() + ->whereUuid($cloud_init_script_uuid) + ->firstOrFail(); + + $this->authorize('view', $this->cloudInitScript); + + $this->name = $this->cloudInitScript->name; + $this->script = $this->cloudInitScript->script; + } catch (AuthorizationException) { + abort(403, 'You do not have permission to view this cloud-init script.'); + } catch (\Throwable) { + abort(404); + } + } + + public function save(): void + { + $this->authorize('update', $this->cloudInitScript); + $this->validate(); + + $this->cloudInitScript->update([ + 'name' => $this->name, + 'script' => $this->script, + ]); + + auditLog('ui.cloud_init_script.updated', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $this->cloudInitScript->id, + 'cloud_init_script_name' => $this->cloudInitScript->name, + ]); + + $this->dispatch('success', 'Cloud-init script updated successfully.'); + } + + public function delete(): mixed + { + $this->authorize('delete', $this->cloudInitScript); + + $scriptId = $this->cloudInitScript->id; + $scriptName = $this->cloudInitScript->name; + + $this->cloudInitScript->delete(); + + auditLog('ui.cloud_init_script.deleted', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $scriptId, + 'cloud_init_script_name' => $scriptName, + ]); + + return redirectRoute($this, 'security.cloud-init-scripts'); + } + + public function render() + { + return view('livewire.security.cloud-init-script.show'); + } +} diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php index 57b7324d3..e66a749cf 100644 --- a/app/Livewire/Security/CloudInitScripts.php +++ b/app/Livewire/Security/CloudInitScripts.php @@ -27,6 +27,13 @@ public function getListeners() public function loadScripts() { + CloudInitScript::ownedByCurrentTeam() + ->whereNull('uuid') + ->get() + ->each(function (CloudInitScript $script): void { + $script->forceFill(['uuid' => new_public_id()])->save(); + }); + $this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get(); } diff --git a/app/Livewire/Security/CloudProviderToken/Show.php b/app/Livewire/Security/CloudProviderToken/Show.php new file mode 100644 index 000000000..aa9270be0 --- /dev/null +++ b/app/Livewire/Security/CloudProviderToken/Show.php @@ -0,0 +1,177 @@ + 'required|string|max:255', + 'description' => 'nullable|string|max:1000', + ]; + } + + protected function messages(): array + { + return [ + 'name.required' => 'Token name is required.', + ]; + } + + public function mount(string $cloud_token_uuid): void + { + try { + $this->cloudProviderToken = CloudProviderToken::ownedByCurrentTeam() + ->whereUuid($cloud_token_uuid) + ->firstOrFail(); + + $this->authorize('view', $this->cloudProviderToken); + + $this->name = $this->cloudProviderToken->name; + $this->description = $this->cloudProviderToken->description; + } catch (AuthorizationException) { + abort(403, 'You do not have permission to view this cloud token.'); + } catch (\Throwable) { + abort(404); + } + } + + public function save(): void + { + $this->authorize('update', $this->cloudProviderToken); + $this->validate(); + + $description = trim($this->description ?? ''); + + $this->cloudProviderToken->update([ + 'name' => $this->name, + 'description' => $description === '' ? null : $description, + ]); + + auditLog('ui.cloud_token.updated', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $this->cloudProviderToken->uuid, + 'cloud_token_name' => $this->cloudProviderToken->name, + 'provider' => $this->cloudProviderToken->provider, + ]); + + $this->dispatch('success', 'Cloud provider token updated.'); + } + + public function validateToken(): void + { + $this->authorize('view', $this->cloudProviderToken); + + $isValid = match ($this->cloudProviderToken->provider) { + 'hetzner' => $this->validateHetznerToken($this->cloudProviderToken->token), + 'digitalocean' => $this->validateDigitalOceanToken($this->cloudProviderToken->token), + 'vultr' => $this->validateVultrToken($this->cloudProviderToken->token), + default => false, + }; + + $providerName = $this->providerName(); + + $this->dispatch( + $isValid ? 'success' : 'error', + $isValid + ? "{$providerName} token is valid." + : "{$providerName} token validation failed. Please check the token." + ); + + auditLog('ui.cloud_token.validated', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $this->cloudProviderToken->uuid, + 'cloud_token_name' => $this->cloudProviderToken->name, + 'provider' => $this->cloudProviderToken->provider, + 'valid' => $isValid, + ]); + } + + public function delete(): mixed + { + $this->authorize('delete', $this->cloudProviderToken); + + if ($this->cloudProviderToken->hasServers()) { + $serverCount = $this->cloudProviderToken->servers()->count(); + $this->dispatch('error', "Cannot delete this token. It is currently used by {$serverCount} server(s). Please reassign those servers to a different token first."); + + return null; + } + + auditLog('ui.cloud_token.deleted', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $this->cloudProviderToken->uuid, + 'cloud_token_name' => $this->cloudProviderToken->name, + 'provider' => $this->cloudProviderToken->provider, + ]); + + $this->cloudProviderToken->delete(); + + return redirectRoute($this, 'security.cloud-tokens'); + } + + public function providerName(): string + { + return match ($this->cloudProviderToken->provider) { + 'digitalocean' => 'DigitalOcean', + 'vultr' => 'Vultr', + default => 'Hetzner', + }; + } + + private function validateHetznerToken(string $token): bool + { + try { + return Http::withToken($token) + ->timeout(10) + ->get('https://api.hetzner.cloud/v1/servers?per_page=1') + ->successful(); + } catch (\Throwable) { + return false; + } + } + + private function validateDigitalOceanToken(string $token): bool + { + try { + return Http::withToken($token) + ->timeout(10) + ->get('https://api.digitalocean.com/v2/account') + ->successful(); + } catch (\Throwable) { + return false; + } + } + + private function validateVultrToken(string $token): bool + { + try { + return Http::withToken($token) + ->timeout(10) + ->get('https://api.vultr.com/v2/account') + ->successful(); + } catch (\Throwable) { + return false; + } + } + + public function render() + { + return view('livewire.security.cloud-provider-token.show'); + } +} diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index 0a7235447..c2466c622 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -22,6 +22,8 @@ class CloudProviderTokenForm extends Component public string $name = ''; + public ?string $description = null; + public function mount() { try { @@ -37,6 +39,7 @@ protected function rules(): array 'provider' => 'required|string|in:hetzner,digitalocean,vultr', 'token' => 'required|string', 'name' => 'required|string|max:255', + 'description' => 'nullable|string|max:1000', ]; } @@ -94,11 +97,14 @@ public function addToken() return $this->dispatch('error', 'Invalid API token. Please check your token and try again.'); } + $description = trim($this->description ?? ''); + $savedToken = CloudProviderToken::create([ 'team_id' => currentTeam()->id, 'provider' => $this->provider, 'token' => $this->token, 'name' => $this->name, + 'description' => $description === '' ? null : $description, ]); auditLog('ui.cloud_token.created', [ @@ -108,7 +114,7 @@ public function addToken() 'provider' => $savedToken->provider, ]); - $this->reset(['token', 'name']); + $this->reset(['token', 'name', 'description']); // Dispatch event with token ID so parent components can react $this->dispatch('tokenAdded', tokenId: $savedToken->id); diff --git a/app/Livewire/Security/PrivateKey/Create.php b/app/Livewire/Security/PrivateKey/Create.php index 8b7ba73dd..8be1011d5 100644 --- a/app/Livewire/Security/PrivateKey/Create.php +++ b/app/Livewire/Security/PrivateKey/Create.php @@ -43,22 +43,6 @@ protected function messages(): array ); } - public function generateNewRSAKey() - { - $this->generateNewKey('rsa'); - } - - public function generateNewEDKey() - { - $this->generateNewKey('ed25519'); - } - - private function generateNewKey($type) - { - $keyData = PrivateKey::generateNewKeyPair($type); - $this->setKeyData($keyData); - } - public function updated($property) { if ($property === 'value') { @@ -93,14 +77,6 @@ public function createPrivateKey() } } - private function setKeyData(array $keyData) - { - $this->name = $keyData['name']; - $this->description = $keyData['description']; - $this->value = $keyData['private_key']; - $this->publicKey = $keyData['public_key']; - } - private function validatePrivateKey() { $validationResult = PrivateKey::validateAndExtractPublicKey($this->value); diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 0362b65fa..540ef5fa1 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -10,6 +10,31 @@ class Index extends Component { use AuthorizesRequests; + public function generatePrivateKey(string $type) + { + 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, + ]); + + return redirectRoute($this, 'security.private-key.show', ['private_key_uuid' => $privateKey->uuid]); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { $privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description', 'team_id'])->get(); diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php index c131e096a..7b0151851 100644 --- a/app/Livewire/Server/New/ByDigitalOcean.php +++ b/app/Livewire/Server/New/ByDigitalOcean.php @@ -12,6 +12,7 @@ use App\Rules\ValidHostname; use App\Services\DigitalOceanService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Component; @@ -57,6 +58,8 @@ class ByDigitalOcean extends Component public bool $loading_data = false; + public ?string $provider_data_error = null; + public bool $enable_ipv6 = true; public bool $monitoring = true; @@ -264,6 +267,7 @@ public function loadDigitalOceanData(): void } $this->loading_data = true; + $this->provider_data_error = null; try { $digitalOceanService = new DigitalOceanService($token); @@ -278,10 +282,22 @@ public function loadDigitalOceanData(): void $this->loading_data = false; } catch (\Throwable $e) { $this->loading_data = false; - throw $e; + $this->provider_data_error = $this->providerDataErrorMessage('DigitalOcean', $e, 'message'); + $this->dispatch('error', $this->provider_data_error); } } + private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string + { + $details = $e->getMessage(); + + if ($e instanceof RequestException && $e->response) { + $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; + } + + return "{$providerName} API error: {$details}"; + } + public function getAvailableSizesProperty(): array { if (! $this->selected_region) { diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 31cd89dae..5ef88acee 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -12,6 +12,7 @@ use App\Rules\ValidHostname; use App\Services\HetznerService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Livewire\Attributes\Locked; @@ -70,6 +71,8 @@ class ByHetzner extends Component public bool $loading_data = false; + public ?string $provider_data_error = null; + public bool $enable_ipv4 = true; public bool $enable_ipv6 = true; @@ -310,6 +313,7 @@ public function loadHetznerData(): void } $this->loading_data = true; + $this->provider_data_error = null; $this->selectedHetznerSshKeyIds = []; $this->selectedHetznerFirewallIds = []; $this->selectedHetznerNetworkIds = []; @@ -353,10 +357,22 @@ public function loadHetznerData(): void $this->loading_data = false; } catch (\Throwable $e) { $this->loading_data = false; - throw $e; + $this->provider_data_error = $this->providerDataErrorMessage('Hetzner', $e, 'error.message'); + $this->dispatch('error', $this->provider_data_error); } } + private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string + { + $details = $e->getMessage(); + + if ($e instanceof RequestException && $e->response) { + $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; + } + + return "{$providerName} API error: {$details}"; + } + private function getCpuVendorInfo(array $serverType): ?string { $name = strtolower($serverType['name'] ?? ''); diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php index 124644845..9e6bc3cf2 100644 --- a/app/Livewire/Server/New/ByVultr.php +++ b/app/Livewire/Server/New/ByVultr.php @@ -12,6 +12,7 @@ use App\Rules\ValidHostname; use App\Services\VultrService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Component; @@ -57,6 +58,8 @@ class ByVultr extends Component public bool $loading_data = false; + public ?string $provider_data_error = null; + public bool $enable_ipv6 = true; public bool $disable_public_ipv4 = false; @@ -334,6 +337,7 @@ public function loadVultrData(): void } $this->loading_data = true; + $this->provider_data_error = null; try { $vultrService = new VultrService($token); @@ -357,10 +361,22 @@ public function loadVultrData(): void $this->loading_data = false; } catch (\Throwable $e) { $this->loading_data = false; - throw $e; + $this->provider_data_error = $this->providerDataErrorMessage('Vultr', $e, 'error'); + $this->dispatch('error', $this->provider_data_error); } } + private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string + { + $details = $e->getMessage(); + + if ($e instanceof RequestException && $e->response) { + $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; + } + + return "{$providerName} API error: {$details}"; + } + private function createVultrServer(string $token): array { $vultrService = new VultrService($token); diff --git a/app/Models/CloudInitScript.php b/app/Models/CloudInitScript.php index 9d2676aca..671c5e76c 100644 --- a/app/Models/CloudInitScript.php +++ b/app/Models/CloudInitScript.php @@ -2,9 +2,7 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Model; - -class CloudInitScript extends Model +class CloudInitScript extends BaseModel { protected $fillable = [ 'team_id', diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 27214f1d1..ab9897f9a 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -13,6 +13,7 @@ class CloudProviderToken extends BaseModel 'provider', 'token', 'name', + 'description', ]; protected $hidden = [ diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index d16213ad7..3f72642a5 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -291,7 +291,7 @@ protected function ensureStorageDirectoryExists() public function getKeyLocation() { - return "/var/www/html/storage/app/ssh/keys/ssh_key@{$this->uuid}"; + return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}"); } public function updatePrivateKey(array $data) diff --git a/database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php b/database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php new file mode 100644 index 000000000..5d194cd7d --- /dev/null +++ b/database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php @@ -0,0 +1,28 @@ +text('description')->nullable()->after('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('cloud_provider_tokens', function (Blueprint $table) { + $table->dropColumn('description'); + }); + } +}; diff --git a/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php new file mode 100644 index 000000000..500fa1fcf --- /dev/null +++ b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php @@ -0,0 +1,33 @@ +string('uuid')->nullable()->unique()->after('id'); + }); + + DB::table('cloud_init_scripts') + ->whereNull('uuid') + ->orderBy('id') + ->each(function (object $script): void { + DB::table('cloud_init_scripts') + ->where('id', $script->id) + ->update(['uuid' => new_public_id()]); + }); + } + + public function down(): void + { + Schema::table('cloud_init_scripts', function (Blueprint $table) { + $table->dropUnique(['uuid']); + $table->dropColumn('uuid'); + }); + } +}; diff --git a/resources/views/livewire/security/cloud-init-script-form.blade.php b/resources/views/livewire/security/cloud-init-script-form.blade.php index 83bedffab..2e1eb86eb 100644 --- a/resources/views/livewire/security/cloud-init-script-form.blade.php +++ b/resources/views/livewire/security/cloud-init-script-form.blade.php @@ -5,13 +5,8 @@ helper="Enter your cloud-init script. Supports cloud-config YAML format." required />
- @if ($modal_mode) - - Cancel - - @endif {{ $scriptId ? 'Update Script' : 'Create Script' }}
- \ No newline at end of file + diff --git a/resources/views/livewire/security/cloud-init-script/show.blade.php b/resources/views/livewire/security/cloud-init-script/show.blade.php new file mode 100644 index 000000000..f8cd4263f --- /dev/null +++ b/resources/views/livewire/security/cloud-init-script/show.blade.php @@ -0,0 +1,28 @@ +
+ + Cloud-Init Script | Coolify + + +
+
+

Cloud-Init Script

+ + Save + + @can('delete', $cloudInitScript) + + @endcan +
+
+ + +
+
+
diff --git a/resources/views/livewire/security/cloud-init-scripts.blade.php b/resources/views/livewire/security/cloud-init-scripts.blade.php index e2013a4fb..4870eeaa6 100644 --- a/resources/views/livewire/security/cloud-init-scripts.blade.php +++ b/resources/views/livewire/security/cloud-init-scripts.blade.php @@ -1,48 +1,30 @@
-
-

Cloud-Init Scripts

+
+

Cloud-Init Scripts

@can('create', App\Models\CloudInitScript::class) @endcan
-
Manage reusable cloud-init scripts for server initialization. Currently working only with Hetzner's integration.
+
Manage reusable cloud-init scripts for server initialization with Hetzner, Vultr, and DigitalOcean integrations.
@forelse ($scripts as $script) -
-
-
-
{{ $script->name }}
-
- Created {{ $script->created_at->diffForHumans() }} + @can('view', $script) + +
+
+ {{ $script->name }} +
+
+ Cloud-init script
-
- -
- @can('update', $script) - - - - @endcan - - @can('delete', $script) - - @endcan -
-
+ + @endcan @empty
No cloud-init scripts found. Create one to get started.
@endforelse diff --git a/resources/views/livewire/security/cloud-provider-token-form.blade.php b/resources/views/livewire/security/cloud-provider-token-form.blade.php index 93f8e21cf..e7170e586 100644 --- a/resources/views/livewire/security/cloud-provider-token-form.blade.php +++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php @@ -15,6 +15,9 @@ + + @@ -33,7 +36,10 @@ @endif
- Validate & Add Token + + Validate & Add Token + + @else {{-- Full page layout: horizontal, spacious --}}
@@ -47,6 +53,10 @@
+
+ +
- Validate & Add Token + + Validate & Add Token + + @endif
diff --git a/resources/views/livewire/security/cloud-provider-token/show.blade.php b/resources/views/livewire/security/cloud-provider-token/show.blade.php new file mode 100644 index 000000000..b814f4051 --- /dev/null +++ b/resources/views/livewire/security/cloud-provider-token/show.blade.php @@ -0,0 +1,39 @@ +
+ + Cloud Token | Coolify + + +
+
+

Cloud Token

+ + Save + + + Validate + + + @can('delete', $cloudProviderToken) + + @endcan +
+
+
+ + +
+
+ + +
+
+
+
diff --git a/resources/views/livewire/security/cloud-provider-tokens.blade.php b/resources/views/livewire/security/cloud-provider-tokens.blade.php index ec7bc976f..17d54e617 100644 --- a/resources/views/livewire/security/cloud-provider-tokens.blade.php +++ b/resources/views/livewire/security/cloud-provider-tokens.blade.php @@ -1,44 +1,87 @@
-

Cloud Provider Tokens

-
Manage API tokens for cloud providers (Hetzner, Vultr, etc.).
+
+

Cloud Provider Tokens

+ @can('create', App\Models\CloudProviderToken::class) +
+ + + Add + + + + -

New Token

- @can('create', App\Models\CloudProviderToken::class) - - @endcan +
+
+
+ + + + + + -

Saved Tokens

-
- @forelse ($tokens as $savedToken) -
-
- - {{ strtoupper($savedToken->provider) }} - - {{ $savedToken->name }} -
-
Created: {{ $savedToken->created_at->diffForHumans() }}
+ + + + + + -
- @can('view', $savedToken) - - Validate - - @endcan - - @can('delete', $savedToken) - - @endcan + + + + + + +
+
+ @endcan +
+
Manage API tokens for cloud providers (Hetzner, Vultr, etc.).
+
+ @forelse ($tokens as $savedToken) + +
+
+ {{ $savedToken->name }} +
+
+ {{ strtoupper($savedToken->provider) }} + @if ($savedToken->description) + ยท {{ $savedToken->description }} + @endif +
+
+
@empty
No cloud provider tokens found.
diff --git a/resources/views/livewire/security/private-key/create.blade.php b/resources/views/livewire/security/private-key/create.blade.php index fb0306265..ec845f743 100644 --- a/resources/views/livewire/security/private-key/create.blade.php +++ b/resources/views/livewire/security/private-key/create.blade.php @@ -3,11 +3,6 @@
Private Keys are used to connect to your servers without passwords.
You should not use passphrase protected keys.
-
- Generate new ED25519 SSH - Key - Generate new RSA SSH Key -
diff --git a/resources/views/livewire/security/private-key/index.blade.php b/resources/views/livewire/security/private-key/index.blade.php index 4f41dc1b9..91a5b3a73 100644 --- a/resources/views/livewire/security/private-key/index.blade.php +++ b/resources/views/livewire/security/private-key/index.blade.php @@ -1,11 +1,55 @@
-
-

Private Keys

+
+

Private Keys

@can('create', App\Models\PrivateKey::class) - - - +
+ + + Add + + + + + +
+
+
+ + + + + Generate ED25519 + + + + + + Generate RSA + + + + + + + +
+
+
+
@endcan @can('create', App\Models\PrivateKey::class)
-
-

Private Key

+
+

Private Key

+ @if ($isGitRelated) + + Used by GitHub App + + @endif Save @@ -35,6 +41,7 @@
Public Key
+ ACTION REQUIRED: Copy the 'Public Key' to your server's ~/.ssh/authorized_keys file
Private Key *
- @if ($isGitRelated) -
- -
- @endif
diff --git a/resources/views/livewire/server/cloud-provider-token/show.blade.php b/resources/views/livewire/server/cloud-provider-token/show.blade.php index b98fb735a..bb6cfb4cb 100644 --- a/resources/views/livewire/server/cloud-provider-token/show.blade.php +++ b/resources/views/livewire/server/cloud-provider-token/show.blade.php @@ -15,8 +15,10 @@ @endcan + wire:click.prevent='validateToken' :showLoadingIndicator="false" wire:loading.attr="disabled" + wire:target="validateToken"> Validate token +
Change your server's {{ $providerName }} token.
@@ -26,6 +28,9 @@ class="box-without-bg justify-between dark:bg-coolgray-100 bg-white items-center flex flex-col gap-2">
{{ $token->name }}
+ @if ($token->description) +
{{ $token->description }}
+ @endif
Created {{ $token->created_at->diffForHumans() }}
diff --git a/resources/views/livewire/server/new/by-digital-ocean.blade.php b/resources/views/livewire/server/new/by-digital-ocean.blade.php index 5cce8d70b..dc4f1deb5 100644 --- a/resources/views/livewire/server/new/by-digital-ocean.blade.php +++ b/resources/views/livewire/server/new/by-digital-ocean.blade.php @@ -60,6 +60,22 @@ class="flex size-10 shrink-0 items-center justify-center rounded-full bg-coollab
+ @elseif ($provider_data_error) +
+
+

Unable to load DigitalOcean details

+

+ Coolify could not fetch DigitalOcean data with the selected token. The token may have been + deleted, revoked, or no longer has access. +

+
+
{{ $provider_data_error }}
+ +
@else
diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php index 86a3bf3f7..fafc13013 100644 --- a/resources/views/livewire/server/new/by-hetzner.blade.php +++ b/resources/views/livewire/server/new/by-hetzner.blade.php @@ -60,6 +60,22 @@ class="flex size-10 shrink-0 items-center justify-center rounded-full bg-coollab
+ @elseif ($provider_data_error) +
+
+

Unable to load Hetzner details

+

+ Coolify could not fetch Hetzner data with the selected token. The token may have been + deleted, revoked, or no longer has access. +

+
+
{{ $provider_data_error }}
+ +
@else
diff --git a/resources/views/livewire/server/new/by-vultr.blade.php b/resources/views/livewire/server/new/by-vultr.blade.php index 89e459e82..d72f58565 100644 --- a/resources/views/livewire/server/new/by-vultr.blade.php +++ b/resources/views/livewire/server/new/by-vultr.blade.php @@ -60,6 +60,22 @@ class="flex size-10 shrink-0 items-center justify-center rounded-full bg-coollab
+ @elseif ($provider_data_error) +
+
+

Unable to load Vultr details

+

+ Coolify could not fetch Vultr data with the selected token. The token may have been + deleted, revoked, or no longer has access. +

+
+
{{ $provider_data_error }}
+ +
@else
diff --git a/routes/web.php b/routes/web.php index c31140a13..f729af28e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -37,7 +37,9 @@ use App\Livewire\Project\Shared\Logs; use App\Livewire\Project\Show as ProjectShow; use App\Livewire\Security\ApiTokens; +use App\Livewire\Security\CloudInitScript\Show as SecurityCloudInitScriptShow; use App\Livewire\Security\CloudInitScripts; +use App\Livewire\Security\CloudProviderToken\Show as SecurityCloudProviderTokenShow; use App\Livewire\Security\CloudTokens; use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex; use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow; @@ -317,7 +319,9 @@ Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show'); Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens'); + Route::get('/security/cloud-tokens/{cloud_token_uuid}', SecurityCloudProviderTokenShow::class)->name('security.cloud-tokens.show'); Route::get('/security/cloud-init-scripts', CloudInitScripts::class)->name('security.cloud-init-scripts'); + Route::get('/security/cloud-init-scripts/{cloud_init_script_uuid}', SecurityCloudInitScriptShow::class)->name('security.cloud-init-scripts.show'); Route::get('/security/api-tokens', ApiTokens::class)->name('security.api-tokens'); }); diff --git a/tests/Feature/Authorization/SecurityPageAuthorizationTest.php b/tests/Feature/Authorization/SecurityPageAuthorizationTest.php index 94975f3c2..7faaf83d1 100644 --- a/tests/Feature/Authorization/SecurityPageAuthorizationTest.php +++ b/tests/Feature/Authorization/SecurityPageAuthorizationTest.php @@ -15,7 +15,11 @@ uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::updateOrCreate(['id' => 0]); + if (! InstanceSettings::query()->whereKey(0)->exists()) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->save(); + } $this->team = Team::factory()->create(); diff --git a/tests/Feature/CloudProviderTokenShowTest.php b/tests/Feature/CloudProviderTokenShowTest.php new file mode 100644 index 000000000..8aa200ded --- /dev/null +++ b/tests/Feature/CloudProviderTokenShowTest.php @@ -0,0 +1,48 @@ +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); +}); + +test('server cloud provider token cards show token descriptions', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'hetzner_server_id' => 12345, + ]); + + CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'name' => 'Production Hetzner', + 'description' => 'Used for production servers in the EU region.', + ]); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->assertSee('Production Hetzner') + ->assertSee('Used for production servers in the EU region.'); +}); diff --git a/tests/Feature/Security/CloudInitScriptsTest.php b/tests/Feature/Security/CloudInitScriptsTest.php new file mode 100644 index 000000000..e161d6aa4 --- /dev/null +++ b/tests/Feature/Security/CloudInitScriptsTest.php @@ -0,0 +1,122 @@ + InstanceSettings::query()->create([ + 'id' => 0, + ])); + + Once::flush(); + + $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]); +}); + +test('cloud-init scripts page lists every supported cloud provider integration', function () { + Livewire::test(CloudInitScripts::class) + ->assertSee('Hetzner') + ->assertSee('Vultr') + ->assertSee('DigitalOcean') + ->assertDontSee('Currently working only with Hetzner'); +}); + +test('cloud-init script form does not show a cancel button in the modal', function () { + Livewire::test(CloudInitScriptForm::class) + ->assertSee('Create Script') + ->assertDontSee('Cancel'); +}); + +test('cloud-init script cards link to the script detail page without inline actions or created time', function () { + $script = CloudInitScript::query()->create([ + 'team_id' => $this->team->id, + 'name' => 'Docker Host Setup', + 'script' => "#cloud-config\npackages:\n - htop\n", + ]); + + Livewire::test(CloudInitScripts::class) + ->assertSee('Docker Host Setup') + ->assertSee(route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]), false) + ->assertDontSee('Created') + ->assertDontSee('Edit') + ->assertDontSee('Delete'); +}); + +test('cloud-init script detail page shows editable script fields', function () { + $script = CloudInitScript::query()->create([ + 'team_id' => $this->team->id, + 'name' => 'Docker Host Setup', + 'script' => "#cloud-config\npackages:\n - htop\n", + ]); + + $this->get(route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid])) + ->assertSuccessful() + ->assertSee('Cloud-Init Script') + ->assertSee('Docker Host Setup') + ->assertSee('Save') + ->assertSee('Delete'); +}); + +test('cloud-init script detail page updates a script', function () { + $script = CloudInitScript::query()->create([ + 'team_id' => $this->team->id, + 'name' => 'Docker Host Setup', + 'script' => "#cloud-config\npackages:\n - htop\n", + ]); + + Livewire::test(Show::class, ['cloud_init_script_uuid' => $script->uuid]) + ->set('name', 'Updated Host Setup') + ->set('script', "#cloud-config\npackages:\n - curl\n") + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $script->refresh(); + + expect($script->name)->toBe('Updated Host Setup') + ->and($script->script)->toContain('curl'); +}); + +test('cloud-init script detail page deletes a script', function () { + $script = CloudInitScript::query()->create([ + 'team_id' => $this->team->id, + 'name' => 'Docker Host Setup', + 'script' => "#cloud-config\npackages:\n - htop\n", + ]); + + Livewire::test(Show::class, ['cloud_init_script_uuid' => $script->uuid]) + ->call('delete') + ->assertRedirectToRoute('security.cloud-init-scripts'); + + $this->assertModelMissing($script); +}); + +test('cloud-init scripts page backfills missing script uuids before rendering links', function () { + $script = CloudInitScript::query()->create([ + 'team_id' => $this->team->id, + 'name' => 'Legacy Script', + 'script' => "#cloud-config\npackages:\n - htop\n", + ]); + + CloudInitScript::query()->whereKey($script->id)->update(['uuid' => null]); + + Livewire::test(CloudInitScripts::class) + ->assertSee('Legacy Script'); + + expect($script->refresh()->uuid)->not->toBeNull(); +}); diff --git a/tests/Feature/Security/CloudProviderTokenFormTest.php b/tests/Feature/Security/CloudProviderTokenFormTest.php index 3ac91bbb1..666a9b120 100644 --- a/tests/Feature/Security/CloudProviderTokenFormTest.php +++ b/tests/Feature/Security/CloudProviderTokenFormTest.php @@ -1,6 +1,8 @@ and($dispatches->contains(fn (array $dispatch) => $dispatch['name'] === 'tokenAdded' && data_get($dispatch, 'to') === 'security.cloud-provider-tokens'))->toBeTrue(); }); + +test('adding a cloud provider token stores an optional description', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/servers' => Http::response([], 200), + ]); + + Livewire::test(CloudProviderTokenForm::class) + ->set('provider', 'hetzner') + ->set('name', 'Production Hetzner') + ->set('description', 'Used for production servers in the EU region.') + ->set('token', 'hetzner-token') + ->call('addToken') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('cloud_provider_tokens', [ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'name' => 'Production Hetzner', + 'description' => 'Used for production servers in the EU region.', + ]); +}); + +test('saved cloud provider tokens show descriptions when present', function () { + CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'Production Hetzner', + 'description' => 'Used for production servers in the EU region.', + ]); + + Livewire::test(CloudProviderTokens::class) + ->assertSee('Production Hetzner') + ->assertSee('Used for production servers in the EU region.'); +}); diff --git a/tests/Feature/Security/CloudProviderTokenShowTest.php b/tests/Feature/Security/CloudProviderTokenShowTest.php new file mode 100644 index 000000000..cb527913f --- /dev/null +++ b/tests/Feature/Security/CloudProviderTokenShowTest.php @@ -0,0 +1,106 @@ +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); +}); + +test('saved cloud token cards link to the token detail page', function () { + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'Production Hetzner', + ]); + + Livewire::test(CloudProviderTokens::class) + ->assertSee(route('security.cloud-tokens.show', ['cloud_token_uuid' => $token->uuid]), false); +}); + +test('cloud token detail page shows editable name and description fields', function () { + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'Production Hetzner', + 'description' => 'Used for production servers.', + ]); + + $this->get(route('security.cloud-tokens.show', ['cloud_token_uuid' => $token->uuid])) + ->assertSuccessful() + ->assertSee('Cloud Token') + ->assertSee('Production Hetzner') + ->assertSee('Used for production servers.') + ->assertSee('Validate') + ->assertSee('Delete'); +}); + +test('cloud token detail page updates name and description', function () { + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'Production Hetzner', + 'description' => 'Used for production servers.', + ]); + + Livewire::test(Show::class, ['cloud_token_uuid' => $token->uuid]) + ->set('name', 'Renamed Hetzner') + ->set('description', 'Used for staging servers.') + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $this->assertDatabaseHas('cloud_provider_tokens', [ + 'id' => $token->id, + 'name' => 'Renamed Hetzner', + 'description' => 'Used for staging servers.', + ]); +}); + +test('cloud token detail page validates the token', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/servers?per_page=1' => Http::response([], 200), + ]); + + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + ]); + + Livewire::test(Show::class, ['cloud_token_uuid' => $token->uuid]) + ->call('validateToken') + ->assertDispatched('success'); +}); + +test('cloud token detail page deletes unused tokens', function () { + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'Production Hetzner', + ]); + + Livewire::test(Show::class, ['cloud_token_uuid' => $token->uuid]) + ->call('delete') + ->assertRedirectToRoute('security.cloud-tokens'); + + $this->assertModelMissing($token); +}); diff --git a/tests/Feature/Security/PrivateKeyDropdownTest.php b/tests/Feature/Security/PrivateKeyDropdownTest.php new file mode 100644 index 000000000..6152e0c2b --- /dev/null +++ b/tests/Feature/Security/PrivateKeyDropdownTest.php @@ -0,0 +1,97 @@ +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); + + Log::spy(); + Config::set('cache.default', 'array'); + Storage::fake('ssh-keys'); +}); + +test('private key index shows highlighted add dropdown actions', function () { + Livewire::test(Index::class) + ->assertSee('+ Add') + ->assertSee('Generate ED25519') + ->assertSee('Generate RSA') + ->assertSee('Add manually') + ->assertDontSee('Manage your SSH keys for your servers and integrations.'); +}); + +test('generating a private key from the index stores it and redirects to details', function () { + $component = Livewire::test(Index::class) + ->call('generatePrivateKey', 'ed25519'); + + $privateKey = PrivateKey::query()->firstOrFail(); + + expect($privateKey->team_id)->toBe($this->team->id) + ->and($privateKey->public_key)->toStartWith('ssh-ed25519'); + + $component->assertRedirect(route('security.private-key.show', [ + 'private_key_uuid' => $privateKey->uuid, + ])); +}); + +test('manual private key form does not expose key generation controls', function () { + Livewire::test(Create::class) + ->assertDontSee('Generate new ED25519 SSH Key') + ->assertDontSee('Generate new RSA SSH Key'); +}); + +test('private key details view reminds users to install the public key', function () { + $view = file_get_contents(resource_path('views/livewire/security/private-key/show.blade.php')); + + expect($view)->toContain("ACTION REQUIRED: Copy the 'Public Key' to your server's ~/.ssh/authorized_keys file"); +}); + +test('github app private key shows a highlighted badge under the title', function () { + $privateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + 'is_git_related' => true, + ]); + + $this->get(route('security.private-key.show', [ + 'private_key_uuid' => $privateKey->uuid, + ])) + ->assertSuccessful() + ->assertSee('Used by GitHub App') + ->assertDontSee('Is used by a Git App?'); +}); + +test('github app badge appears before the save button in the title row', function () { + $view = file_get_contents(resource_path('views/livewire/security/private-key/show.blade.php')); + + $badgePosition = strpos($view, 'Used by GitHub App'); + $saveButtonPosition = strpos($view, ''); + + expect($view)->toContain('
') + ->and($view)->toContain('Used by GitHub App') + ->and($view)->toContain('') + ->and($badgePosition)->toBeLessThan($saveButtonPosition); +}); diff --git a/tests/Feature/Server/NewServerProviderTokenUrlTest.php b/tests/Feature/Server/NewServerProviderTokenUrlTest.php index b5c23b929..320d020e1 100644 --- a/tests/Feature/Server/NewServerProviderTokenUrlTest.php +++ b/tests/Feature/Server/NewServerProviderTokenUrlTest.php @@ -129,6 +129,33 @@ 'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean', 'Loading DigitalOcean data...', 'wire:init="loadDigitalOceanData"'], ]); +test('provider token pages render api error details when a selected token is invalid', function (string $component, string $provider, string $type, string $loadMethod, string $providerName, string $apiMessage) { + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => $provider, + 'name' => $providerName.' Token', + ]); + + Http::fake([ + '*' => Http::response($apiMessage, 401), + ]); + + Livewire::test($component, ['selectedTokenUuid' => $token->uuid]) + ->call($loadMethod) + ->assertSet('loading_data', false) + ->assertSet('provider_data_error', "{$providerName} API error: {$apiMessage}") + ->assertDispatched('error') + ->assertSee("Unable to load {$providerName} details") + ->assertSee($apiMessage) + ->assertSee('href="'.route('server.create.type', ['type' => $type]).'"', false) + ->assertSee('wire:navigate', false) + ->assertDontSee('wire:click="previousStep"', false); +})->with([ + 'hetzner' => [ByHetzner::class, 'hetzner', 'hetzner', 'loadHetznerData', 'Hetzner', 'the token you have provided is invalid'], + 'vultr' => [ByVultr::class, 'vultr', 'vultr', 'loadVultrData', 'Vultr', 'Invalid API key'], + 'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean', 'loadDigitalOceanData', 'DigitalOcean', 'Unable to authenticate you'], +]); + test('back button on token specific provider creation pages returns to provider token selection', function (string $provider, string $type) { $token = CloudProviderToken::factory()->create([ 'team_id' => $this->team->id, diff --git a/tests/Unit/CloudProviderTokenFormViewTest.php b/tests/Unit/CloudProviderTokenFormViewTest.php index 64be1328b..26f7e83f6 100644 --- a/tests/Unit/CloudProviderTokenFormViewTest.php +++ b/tests/Unit/CloudProviderTokenFormViewTest.php @@ -27,3 +27,40 @@ ->and($vultrView)->toContain('https://coolify.io/vultr') ->and($digitalOceanView)->toContain('https://coolify.io/digitalocean'); }); + +it('uses a provider dropdown and modal forms on cloud provider tokens page', function () { + $view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php'); + + expect($view)->toContain('+ Add') + ->and($view)->toContain('
') + ->and($view)->toContain('
') + ->and($view)->toContain('class="coolbox group"') + ->and($view)->toContain('
') + ->and($view)->toContain('
') + ->and($view)->toContain('') + ->and($view)->toContain('Add Hetzner Token') + ->and($view)->toContain('Add DigitalOcean Token') + ->and($view)->toContain('Add Vultr Token') + ->and($view)->toContain('and($view)->toContain('and($view)->toContain('and($view)->not->toContain('

New Token

') + ->and($view)->not->toContain(':modal_mode="false"') + ->and($view)->not->toContain('wire:click="validateToken') + ->and($view)->not->toContain('submitAction="deleteToken') + ->and($view)->not->toContain('Created {{ $savedToken->created_at->diffForHumans() }}'); +}); + +it('shows explicit loading spinners and disables cloud token action buttons while requests run', function () { + $tokenFormView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php'); + $tokenShowView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token/show.blade.php'); + $serverTokenView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/cloud-provider-token/show.blade.php'); + + expect(substr_count($tokenFormView, 'wire:loading.attr="disabled" wire:target="addToken"'))->toBe(2) + ->and(substr_count($tokenFormView, ''))->toBe(2) + ->and($tokenShowView)->toContain('wire:loading.attr="disabled" wire:target="validateToken"') + ->and($tokenShowView)->toContain('') + ->and($serverTokenView)->toContain('wire:loading.attr="disabled"') + ->and($serverTokenView)->toContain('wire:target="validateToken"') + ->and($serverTokenView)->toContain(''); +});