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.
This commit is contained in:
parent
7236cb8228
commit
e200d881f5
36 changed files with 1196 additions and 126 deletions
97
app/Livewire/Security/CloudInitScript/Show.php
Normal file
97
app/Livewire/Security/CloudInitScript/Show.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Security\CloudInitScript;
|
||||
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Rules\ValidCloudInitYaml;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public CloudInitScript $cloudInitScript;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $script = '';
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => '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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
177
app/Livewire/Security/CloudProviderToken/Show.php
Normal file
177
app/Livewire/Security/CloudProviderToken/Show.php
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Security\CloudProviderToken;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public CloudProviderToken $cloudProviderToken;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public ?string $description = null;
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => '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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'] ?? '');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CloudInitScript extends Model
|
||||
class CloudInitScript extends BaseModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class CloudProviderToken extends BaseModel
|
|||
'provider',
|
||||
'token',
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cloud_provider_tokens', function (Blueprint $table) {
|
||||
$table->text('description')->nullable()->after('name');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cloud_provider_tokens', function (Blueprint $table) {
|
||||
$table->dropColumn('description');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cloud_init_scripts', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -5,13 +5,8 @@
|
|||
helper="Enter your cloud-init script. Supports cloud-config YAML format." required />
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
@if ($modal_mode)
|
||||
<x-forms.button type="button" @click="$dispatch('closeModal')">
|
||||
Cancel
|
||||
</x-forms.button>
|
||||
@endif
|
||||
<x-forms.button type="submit" isHighlighted>
|
||||
{{ $scriptId ? 'Update Script' : 'Create Script' }}
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<div>
|
||||
<x-slot:title>
|
||||
Cloud-Init Script | Coolify
|
||||
</x-slot>
|
||||
<x-security.navbar />
|
||||
<form class="flex flex-col" wire:submit="save">
|
||||
<div class="flex items-start gap-2">
|
||||
<h2 class="pb-4">Cloud-Init Script</h2>
|
||||
<x-forms.button canGate="update" :canResource="$cloudInitScript" type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
@can('delete', $cloudInitScript)
|
||||
<x-modal-confirmation title="Confirm Script Deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="delete" :actions="[
|
||||
'This cloud-init script will be permanently deleted.',
|
||||
'This action cannot be undone.',
|
||||
]" confirmationText="{{ $cloudInitScript->name }}"
|
||||
confirmationLabel="Please confirm the deletion by entering the script name below"
|
||||
shortConfirmationLabel="Script Name" :confirmWithPassword="false" step2ButtonText="Delete Script" />
|
||||
@endcan
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$cloudInitScript" id="name" label="Script Name" required />
|
||||
<x-forms.textarea canGate="update" :canResource="$cloudInitScript" id="script" label="Script Content" rows="12"
|
||||
helper="Enter your cloud-init script. Supports cloud-config YAML format." required />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -1,48 +1,30 @@
|
|||
<div>
|
||||
<x-security.navbar />
|
||||
<div class="flex gap-2">
|
||||
<h2 class="pb-4">Cloud-Init Scripts</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>Cloud-Init Scripts</h2>
|
||||
@can('create', App\Models\CloudInitScript::class)
|
||||
<x-modal-input buttonTitle="+ Add" title="New Cloud-Init Script">
|
||||
<livewire:security.cloud-init-script-form />
|
||||
</x-modal-input>
|
||||
@endcan
|
||||
</div>
|
||||
<div class="pb-4 text-sm">Manage reusable cloud-init scripts for server initialization. Currently working only with <span class="text-red-500 font-bold">Hetzner's</span> integration.</div>
|
||||
<div class="pb-4 text-sm">Manage reusable cloud-init scripts for server initialization with Hetzner, Vultr, and DigitalOcean integrations.</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
@forelse ($scripts as $script)
|
||||
<div wire:key="script-{{ $script->id }}"
|
||||
class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underline">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex-1">
|
||||
<div class="font-bold dark:text-white">{{ $script->name }}</div>
|
||||
<div class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Created {{ $script->created_at->diffForHumans() }}
|
||||
@can('view', $script)
|
||||
<a wire:key="script-{{ $script->id }}" class="coolbox group"
|
||||
href="{{ route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]) }}" {{ wireNavigate() }}>
|
||||
<div class="flex flex-col justify-center mx-6">
|
||||
<div class="box-title">
|
||||
{{ $script->name }}
|
||||
</div>
|
||||
<div class="box-description">
|
||||
Cloud-init script
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
@can('update', $script)
|
||||
<x-modal-input buttonTitle="Edit" title="Edit Cloud-Init Script" fullWidth>
|
||||
<livewire:security.cloud-init-script-form :scriptId="$script->id"
|
||||
wire:key="edit-{{ $script->id }}" />
|
||||
</x-modal-input>
|
||||
@endcan
|
||||
|
||||
@can('delete', $script)
|
||||
<x-modal-confirmation title="Confirm Script Deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="deleteScript({{ $script->id }})" :actions="[
|
||||
'This cloud-init script will be permanently deleted.',
|
||||
'This action cannot be undone.',
|
||||
]" confirmationText="{{ $script->name }}"
|
||||
confirmationLabel="Please confirm the deletion by entering the script name below"
|
||||
shortConfirmationLabel="Script Name" :confirmWithPassword="false"
|
||||
step2ButtonText="Delete Script" />
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endcan
|
||||
@empty
|
||||
<div class="text-neutral-500">No cloud-init scripts found. Create one to get started.</div>
|
||||
@endforelse
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@
|
|||
<x-forms.input required id="name" label="Token Name"
|
||||
placeholder="e.g., Production {{ $provider === 'digitalocean' ? 'DigitalOcean' : ucfirst($provider) }} token. tip: add project name to identify easier" />
|
||||
|
||||
<x-forms.textarea id="description" label="Description" rows="3"
|
||||
placeholder="Optional notes about where this token is used" />
|
||||
|
||||
<x-forms.input required type="password" id="token" label="API Token"
|
||||
placeholder="Enter your API token" />
|
||||
|
||||
|
|
@ -33,7 +36,10 @@
|
|||
@endif
|
||||
</div>
|
||||
|
||||
<x-forms.button type="submit">Validate & Add Token</x-forms.button>
|
||||
<x-forms.button type="submit" :showLoadingIndicator="false" wire:loading.attr="disabled" wire:target="addToken">
|
||||
Validate & Add Token
|
||||
<x-loading-on-button wire:loading wire:target="addToken" />
|
||||
</x-forms.button>
|
||||
@else
|
||||
{{-- Full page layout: horizontal, spacious --}}
|
||||
<div class="flex gap-2 items-end flex-wrap">
|
||||
|
|
@ -47,6 +53,10 @@
|
|||
<div class="flex-1 min-w-64">
|
||||
<x-forms.input required id="name" label="Token Name" placeholder="e.g., Production cloud token" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-64">
|
||||
<x-forms.textarea id="description" label="Description" rows="3"
|
||||
placeholder="Optional notes about where this token is used" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-64">
|
||||
<x-forms.input required type="password" id="token" label="API Token"
|
||||
|
|
@ -66,7 +76,10 @@
|
|||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<x-forms.button type="submit">Validate & Add Token</x-forms.button>
|
||||
<x-forms.button type="submit" :showLoadingIndicator="false" wire:loading.attr="disabled" wire:target="addToken">
|
||||
Validate & Add Token
|
||||
<x-loading-on-button wire:loading wire:target="addToken" />
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<div>
|
||||
<x-slot:title>
|
||||
Cloud Token | Coolify
|
||||
</x-slot>
|
||||
<x-security.navbar />
|
||||
<form class="flex flex-col" wire:submit="save">
|
||||
<div class="flex items-start gap-2">
|
||||
<h2 class="pb-4">Cloud Token</h2>
|
||||
<x-forms.button canGate="update" :canResource="$cloudProviderToken" type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
<x-forms.button canGate="view" :canResource="$cloudProviderToken" type="button" wire:click="validateToken"
|
||||
:showLoadingIndicator="false" wire:loading.attr="disabled" wire:target="validateToken">
|
||||
Validate
|
||||
<x-loading-on-button wire:loading wire:target="validateToken" />
|
||||
</x-forms.button>
|
||||
@can('delete', $cloudProviderToken)
|
||||
<x-modal-confirmation title="Confirm Token Deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="delete" :actions="[
|
||||
'This cloud provider token will be permanently deleted.',
|
||||
'Any servers using this token will need to be reconfigured.',
|
||||
]"
|
||||
confirmationText="{{ $cloudProviderToken->name }}"
|
||||
confirmationLabel="Please confirm the deletion by entering the token name below"
|
||||
shortConfirmationLabel="Token Name" :confirmWithPassword="false" step2ButtonText="Delete Token" />
|
||||
@endcan
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$cloudProviderToken" id="name" label="Name" required />
|
||||
<x-forms.input canGate="update" :canResource="$cloudProviderToken" id="description" label="Description" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input readonly label="Provider" :value="$this->providerName()" />
|
||||
<x-forms.input readonly label="Created" :value="$cloudProviderToken->created_at->diffForHumans()" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -1,44 +1,87 @@
|
|||
<div>
|
||||
<h2>Cloud Provider Tokens</h2>
|
||||
<div class="pb-4">Manage API tokens for cloud providers (Hetzner, Vultr, etc.).</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>Cloud Provider Tokens</h2>
|
||||
@can('create', App\Models\CloudProviderToken::class)
|
||||
<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>
|
||||
|
||||
<h3>New Token</h3>
|
||||
@can('create', App\Models\CloudProviderToken::class)
|
||||
<livewire:security.cloud-provider-token-form :modal_mode="false" />
|
||||
@endcan
|
||||
<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">
|
||||
<x-modal-input title="Add Hetzner Token">
|
||||
<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>
|
||||
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner"
|
||||
wire:key="cloud-provider-token-hetzner" />
|
||||
</x-modal-input>
|
||||
|
||||
<h3 class="py-4">Saved Tokens</h3>
|
||||
<div class="grid gap-2 lg:grid-cols-1">
|
||||
@forelse ($tokens as $savedToken)
|
||||
<div wire:key="token-{{ $savedToken->id }}"
|
||||
class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underline">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-1 text-xs font-bold rounded dark:bg-coolgray-300 dark:text-white">
|
||||
{{ strtoupper($savedToken->provider) }}
|
||||
</span>
|
||||
<span class="font-bold dark:text-white">{{ $savedToken->name }}</span>
|
||||
</div>
|
||||
<div class="text-sm">Created: {{ $savedToken->created_at->diffForHumans() }}</div>
|
||||
<x-modal-input title="Add DigitalOcean Token">
|
||||
<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>
|
||||
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="digitalocean"
|
||||
wire:key="cloud-provider-token-digitalocean" />
|
||||
</x-modal-input>
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
@can('view', $savedToken)
|
||||
<x-forms.button wire:click="validateToken({{ $savedToken->id }})" type="button">
|
||||
Validate
|
||||
</x-forms.button>
|
||||
@endcan
|
||||
|
||||
@can('delete', $savedToken)
|
||||
<x-modal-confirmation title="Confirm Token Deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="deleteToken({{ $savedToken->id }})" :actions="[
|
||||
'This cloud provider token will be permanently deleted.',
|
||||
'Any servers using this token will need to be reconfigured.',
|
||||
]"
|
||||
confirmationText="{{ $savedToken->name }}"
|
||||
confirmationLabel="Please confirm the deletion by entering the token name below"
|
||||
shortConfirmationLabel="Token Name" :confirmWithPassword="false" step2ButtonText="Delete Token" />
|
||||
@endcan
|
||||
<x-modal-input title="Add Vultr Token">
|
||||
<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>
|
||||
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="vultr"
|
||||
wire:key="cloud-provider-token-vultr" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
<div class="pb-4">Manage API tokens for cloud providers (Hetzner, Vultr, etc.).</div>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
@forelse ($tokens as $savedToken)
|
||||
<a wire:key="token-{{ $savedToken->id }}" class="coolbox group"
|
||||
href="{{ route('security.cloud-tokens.show', ['cloud_token_uuid' => $savedToken->uuid]) }}" {{ wireNavigate() }}>
|
||||
<div class="flex flex-col justify-center mx-6">
|
||||
<div class="box-title">
|
||||
{{ $savedToken->name }}
|
||||
</div>
|
||||
<div class="box-description">
|
||||
{{ strtoupper($savedToken->provider) }}
|
||||
@if ($savedToken->description)
|
||||
· {{ $savedToken->description }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@empty
|
||||
<div>
|
||||
<div>No cloud provider tokens found.</div>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,6 @@
|
|||
<div>Private Keys are used to connect to your servers without passwords.</div>
|
||||
<div class="font-bold">You should not use passphrase protected keys.</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mb-4 w-full">
|
||||
<x-forms.button wire:click="generateNewEDKey" isHighlighted class="w-full">Generate new ED25519 SSH
|
||||
Key</x-forms.button>
|
||||
<x-forms.button wire:click="generateNewRSAKey">Generate new RSA SSH Key</x-forms.button>
|
||||
</div>
|
||||
<form class="flex flex-col gap-2" wire:submit='createPrivateKey'>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input id="name" label="Name" required />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,55 @@
|
|||
<div>
|
||||
<x-security.navbar />
|
||||
<div class="flex gap-2">
|
||||
<h2 class="pb-4">Private Keys</h2>
|
||||
<div class="flex items-center gap-2 pb-4">
|
||||
<h2>Private Keys</h2>
|
||||
@can('create', App\Models\PrivateKey::class)
|
||||
<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
|
||||
@can('create', App\Models\PrivateKey::class)
|
||||
<x-modal-confirmation title="Confirm unused SSH Key Deletion?" buttonTitle="Delete unused SSH Keys" isErrorButton
|
||||
|
|
|
|||
|
|
@ -5,8 +5,14 @@
|
|||
<x-security.navbar />
|
||||
<div x-data="{ showPrivateKey: false }">
|
||||
<form class="flex flex-col" wire:submit='changePrivateKey'>
|
||||
<div class="flex items-start gap-2">
|
||||
<h2 class="pb-4">Private Key</h2>
|
||||
<div class="flex items-center gap-2 pb-4">
|
||||
<h2>Private Key</h2>
|
||||
@if ($isGitRelated)
|
||||
<span
|
||||
class="w-fit rounded-sm border border-coollabs bg-coollabs-50 px-2 py-1 text-xs font-bold text-coollabs-200 dark:border-coollabs-100 dark:bg-coollabs/20 dark:text-white">
|
||||
Used by GitHub App
|
||||
</span>
|
||||
@endif
|
||||
<x-forms.button canGate="update" :canResource="$private_key" type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
|
|
@ -35,6 +41,7 @@
|
|||
<div class="pl-1">Public Key</div>
|
||||
</div>
|
||||
<x-forms.input canGate="update" :canResource="$private_key" readonly id="public_key" />
|
||||
<span class="block pt-2 pb-4 font-bold dark:text-warning">ACTION REQUIRED: Copy the 'Public Key' to your server's ~/.ssh/authorized_keys file</span>
|
||||
<div class="flex items-end gap-2 py-2 ">
|
||||
<div class="pl-1">Private Key <span class='text-helper'>*</span></div>
|
||||
<div class="text-xs underline cursor-pointer dark:text-white" x-cloak x-show="!showPrivateKey"
|
||||
|
|
@ -46,11 +53,6 @@
|
|||
Hide
|
||||
</div>
|
||||
</div>
|
||||
@if ($isGitRelated)
|
||||
<div class="w-48">
|
||||
<x-forms.checkbox id="isGitRelated" disabled label="Is used by a Git App?" />
|
||||
</div>
|
||||
@endif
|
||||
<div x-cloak x-show="!showPrivateKey">
|
||||
<x-forms.input canGate="update" :canResource="$private_key" allowToPeak="false" type="password" rows="10" id="privateKeyValue"
|
||||
required disabled />
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@
|
|||
</x-modal-input>
|
||||
@endcan
|
||||
<x-forms.button canGate="update" :canResource="$server" isHighlighted
|
||||
wire:click.prevent='validateToken'>
|
||||
wire:click.prevent='validateToken' :showLoadingIndicator="false" wire:loading.attr="disabled"
|
||||
wire:target="validateToken">
|
||||
Validate token
|
||||
<x-loading-on-button wire:loading wire:target="validateToken" />
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="pb-4">Change your server's {{ $providerName }} token.</div>
|
||||
|
|
@ -26,6 +28,9 @@
|
|||
class="box-without-bg justify-between dark:bg-coolgray-100 bg-white items-center flex flex-col gap-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="box-title">{{ $token->name }}</div>
|
||||
@if ($token->description)
|
||||
<div class="box-description">{{ $token->description }}</div>
|
||||
@endif
|
||||
<div class="box-description">
|
||||
Created {{ $token->created_at->diffForHumans() }}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -60,6 +60,22 @@ class="flex size-10 shrink-0 items-center justify-center rounded-full bg-coollab
|
|||
<div class="flex items-center justify-center py-8">
|
||||
<x-loading text="Loading DigitalOcean data..." />
|
||||
</div>
|
||||
@elseif ($provider_data_error)
|
||||
<div class="flex flex-col gap-4 rounded-lg border border-error bg-error/10 p-4">
|
||||
<div>
|
||||
<h3>Unable to load DigitalOcean details</h3>
|
||||
<p class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Coolify could not fetch DigitalOcean data with the selected token. The token may have been
|
||||
deleted, revoked, or no longer has access.
|
||||
</p>
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap break-words text-sm text-error">{{ $provider_data_error }}</pre>
|
||||
<div>
|
||||
<a class="button" href="{{ route('server.create.type', ['type' => 'digital-ocean']) }}" {{ wireNavigate() }}>
|
||||
Select another token
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<form class="flex flex-col w-full gap-2" wire:submit='submit'>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -60,6 +60,22 @@ class="flex size-10 shrink-0 items-center justify-center rounded-full bg-coollab
|
|||
<div class="flex items-center justify-center py-8">
|
||||
<x-loading text="Loading Hetzner data..." />
|
||||
</div>
|
||||
@elseif ($provider_data_error)
|
||||
<div class="flex flex-col gap-4 rounded-lg border border-error bg-error/10 p-4">
|
||||
<div>
|
||||
<h3>Unable to load Hetzner details</h3>
|
||||
<p class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Coolify could not fetch Hetzner data with the selected token. The token may have been
|
||||
deleted, revoked, or no longer has access.
|
||||
</p>
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap break-words text-sm text-error">{{ $provider_data_error }}</pre>
|
||||
<div>
|
||||
<a class="button" href="{{ route('server.create.type', ['type' => 'hetzner']) }}" {{ wireNavigate() }}>
|
||||
Select another token
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<form class="flex flex-col w-full gap-2" wire:submit='submit'>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -60,6 +60,22 @@ class="flex size-10 shrink-0 items-center justify-center rounded-full bg-coollab
|
|||
<div class="flex items-center justify-center py-8">
|
||||
<x-loading text="Loading Vultr data..." />
|
||||
</div>
|
||||
@elseif ($provider_data_error)
|
||||
<div class="flex flex-col gap-4 rounded-lg border border-error bg-error/10 p-4">
|
||||
<div>
|
||||
<h3>Unable to load Vultr details</h3>
|
||||
<p class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Coolify could not fetch Vultr data with the selected token. The token may have been
|
||||
deleted, revoked, or no longer has access.
|
||||
</p>
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap break-words text-sm text-error">{{ $provider_data_error }}</pre>
|
||||
<div>
|
||||
<a class="button" href="{{ route('server.create.type', ['type' => 'vultr']) }}" {{ wireNavigate() }}>
|
||||
Select another token
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<form class="flex flex-col w-full gap-2" wire:submit='submit'>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
48
tests/Feature/CloudProviderTokenShowTest.php
Normal file
48
tests/Feature/CloudProviderTokenShowTest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\CloudProviderToken\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\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);
|
||||
});
|
||||
|
||||
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.');
|
||||
});
|
||||
122
tests/Feature/Security/CloudInitScriptsTest.php
Normal file
122
tests/Feature/Security/CloudInitScriptsTest.php
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Security\CloudInitScript\Show;
|
||||
use App\Livewire\Security\CloudInitScriptForm;
|
||||
use App\Livewire\Security\CloudInitScripts;
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => 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();
|
||||
});
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Security\CloudProviderTokenForm;
|
||||
use App\Livewire\Security\CloudProviderTokens;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
|
@ -52,3 +54,36 @@
|
|||
->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.');
|
||||
});
|
||||
|
|
|
|||
106
tests/Feature/Security/CloudProviderTokenShowTest.php
Normal file
106
tests/Feature/Security/CloudProviderTokenShowTest.php
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Security\CloudProviderToken\Show;
|
||||
use App\Livewire\Security\CloudProviderTokens;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
97
tests/Feature/Security/PrivateKeyDropdownTest.php
Normal file
97
tests/Feature/Security/PrivateKeyDropdownTest.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Security\PrivateKey\Create;
|
||||
use App\Livewire\Security\PrivateKey\Index;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
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);
|
||||
|
||||
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, '<x-forms.button canGate="update" :canResource="$private_key" type="submit">');
|
||||
|
||||
expect($view)->toContain('<div class="flex items-center gap-2 pb-4">')
|
||||
->and($view)->toContain('Used by GitHub App')
|
||||
->and($view)->toContain('<x-forms.button canGate="update" :canResource="$private_key" type="submit">')
|
||||
->and($badgePosition)->toBeLessThan($saveButtonPosition);
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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('<div class="flex items-center gap-2">')
|
||||
->and($view)->toContain('<div class="grid gap-4 lg:grid-cols-2">')
|
||||
->and($view)->toContain('class="coolbox group"')
|
||||
->and($view)->toContain('<div class="box-title">')
|
||||
->and($view)->toContain('<div class="box-description">')
|
||||
->and($view)->toContain('<x-forms.button isHighlighted @click="dropdownOpen = !dropdownOpen" type="button">')
|
||||
->and($view)->toContain('Add Hetzner Token')
|
||||
->and($view)->toContain('Add DigitalOcean Token')
|
||||
->and($view)->toContain('Add Vultr Token')
|
||||
->and($view)->toContain('<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner"')
|
||||
->and($view)->toContain('<livewire:security.cloud-provider-token-form :modal_mode="true" provider="digitalocean"')
|
||||
->and($view)->toContain('<livewire:security.cloud-provider-token-form :modal_mode="true" provider="vultr"')
|
||||
->and($view)->not->toContain('<h3>New Token</h3>')
|
||||
->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, '<x-loading-on-button wire:loading wire:target="addToken" />'))->toBe(2)
|
||||
->and($tokenShowView)->toContain('wire:loading.attr="disabled" wire:target="validateToken"')
|
||||
->and($tokenShowView)->toContain('<x-loading-on-button wire:loading wire:target="validateToken" />')
|
||||
->and($serverTokenView)->toContain('wire:loading.attr="disabled"')
|
||||
->and($serverTokenView)->toContain('wire:target="validateToken"')
|
||||
->and($serverTokenView)->toContain('<x-loading-on-button wire:loading wire:target="validateToken" />');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue