Merge remote-tracking branch 'origin/next' into 10870-pr-previews-ignore
This commit is contained in:
commit
e325f93662
72 changed files with 3366 additions and 737 deletions
|
|
@ -1134,6 +1134,12 @@ private function loadCreatableItems()
|
|||
|
||||
public function navigateToResource($type)
|
||||
{
|
||||
if ($type === 'server') {
|
||||
$this->dispatch('closeSearchModal');
|
||||
|
||||
return redirectRoute($this, 'server.create');
|
||||
}
|
||||
|
||||
// Find the item by type - check regular items first, then services
|
||||
$item = collect($this->creatableItems)->firstWhere('type', $type);
|
||||
|
||||
|
|
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -22,8 +23,12 @@ class Show extends Component
|
|||
|
||||
public bool $isGitRelated = false;
|
||||
|
||||
public bool $isInUse = false;
|
||||
|
||||
public $public_key = 'Loading...';
|
||||
|
||||
public string $deleteDisabledReason = 'This private key is currently used by a server, application, or Git app and cannot be deleted.';
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
|
|
@ -74,16 +79,17 @@ private function syncData(bool $toModel = false): void
|
|||
}
|
||||
}
|
||||
|
||||
public function mount()
|
||||
public function mount(?string $private_key_uuid = null)
|
||||
{
|
||||
try {
|
||||
$this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid(request()->private_key_uuid)->firstOrFail();
|
||||
$this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid($private_key_uuid ?? request()->private_key_uuid)->firstOrFail();
|
||||
|
||||
// Explicit authorization check - will throw 403 if not authorized
|
||||
$this->authorize('view', $this->private_key);
|
||||
|
||||
$this->syncData(false);
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
$this->isInUse = $this->private_key->isInUse();
|
||||
} catch (AuthorizationException $e) {
|
||||
abort(403, 'You do not have permission to view this private key.');
|
||||
} catch (\Throwable) {
|
||||
abort(404);
|
||||
|
|
@ -102,7 +108,15 @@ public function delete()
|
|||
{
|
||||
try {
|
||||
$this->authorize('delete', $this->private_key);
|
||||
$this->private_key->safeDelete();
|
||||
|
||||
if ($this->private_key->isInUse()) {
|
||||
$this->isInUse = true;
|
||||
$this->dispatch('error', $this->deleteDisabledReason);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->private_key->delete();
|
||||
currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get();
|
||||
|
||||
return redirectRoute($this, 'security.private-key.index');
|
||||
|
|
|
|||
|
|
@ -11,12 +11,21 @@ class Create extends Component
|
|||
{
|
||||
public $private_keys = [];
|
||||
|
||||
public ?string $selectedType = null;
|
||||
|
||||
public ?string $selectedTokenUuid = null;
|
||||
|
||||
public bool $limit_reached = false;
|
||||
|
||||
public bool $has_hetzner_tokens = false;
|
||||
|
||||
public function mount()
|
||||
public function mount(?string $selectedType = null, ?string $selectedTokenUuid = null): void
|
||||
{
|
||||
$this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'manual'], true)
|
||||
? $selectedType
|
||||
: null;
|
||||
$this->selectedTokenUuid = $this->selectedType && $this->selectedType !== 'manual' ? $selectedTokenUuid : null;
|
||||
|
||||
$this->private_keys = PrivateKey::ownedByCurrentTeamCached();
|
||||
if (! isCloud()) {
|
||||
$this->limit_reached = false;
|
||||
|
|
|
|||
55
app/Livewire/Server/CreatePage.php
Normal file
55
app/Livewire/Server/CreatePage.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class CreatePage extends Component
|
||||
{
|
||||
public ?string $type = null;
|
||||
|
||||
public ?string $token_uuid = null;
|
||||
|
||||
public string $title = 'New Server';
|
||||
|
||||
public ?string $tokenProvider = null;
|
||||
|
||||
public ?string $tokenProviderName = null;
|
||||
|
||||
public bool $hasProviderTokens = false;
|
||||
|
||||
public function mount(?string $type = null, ?string $token_uuid = null): void
|
||||
{
|
||||
$this->type = $type;
|
||||
$this->token_uuid = $token_uuid;
|
||||
$this->tokenProvider = match ($type) {
|
||||
'hetzner' => 'hetzner',
|
||||
'vultr' => 'vultr',
|
||||
'digital-ocean' => 'digitalocean',
|
||||
default => null,
|
||||
};
|
||||
$this->tokenProviderName = match ($type) {
|
||||
'hetzner' => 'Hetzner',
|
||||
'vultr' => 'Vultr',
|
||||
'digital-ocean' => 'DigitalOcean',
|
||||
default => null,
|
||||
};
|
||||
$this->hasProviderTokens = $this->tokenProvider
|
||||
? CloudProviderToken::ownedByCurrentTeam()->where('provider', $this->tokenProvider)->exists()
|
||||
: false;
|
||||
$this->title = match ($type) {
|
||||
'hetzner' => 'Hetzner',
|
||||
'vultr' => 'Vultr',
|
||||
'digital-ocean' => 'DigitalOcean',
|
||||
'manual' => 'Manual',
|
||||
default => 'New Server',
|
||||
};
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.server.create-page');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -33,6 +34,8 @@ class ByDigitalOcean extends Component
|
|||
|
||||
public ?int $selected_token_id = null;
|
||||
|
||||
public ?string $selectedTokenUuid = null;
|
||||
|
||||
public array $regions = [];
|
||||
|
||||
public array $images = [];
|
||||
|
|
@ -55,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;
|
||||
|
|
@ -74,11 +79,12 @@ class ByDigitalOcean extends Component
|
|||
|
||||
public bool $from_onboarding = false;
|
||||
|
||||
public function mount()
|
||||
public function mount(?string $selectedTokenUuid = null)
|
||||
{
|
||||
try {
|
||||
$this->authorize('viewAny', CloudProviderToken::class);
|
||||
$this->loadTokens();
|
||||
$this->selectTokenFromUrl($selectedTokenUuid);
|
||||
$this->loadSavedCloudInitScripts();
|
||||
$this->server_name = generate_random_name();
|
||||
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
|
||||
|
|
@ -86,6 +92,11 @@ public function mount()
|
|||
if ($this->private_keys->count() > 0) {
|
||||
$this->private_key_id = $this->private_keys->first()->id;
|
||||
}
|
||||
|
||||
if ($this->selectedTokenUuid) {
|
||||
$this->current_step = 2;
|
||||
$this->loading_data = true;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -174,9 +185,27 @@ protected function messages(): array
|
|||
];
|
||||
}
|
||||
|
||||
public function selectToken(int $tokenId): void
|
||||
public function selectToken(int $tokenId): mixed
|
||||
{
|
||||
$this->selected_token_id = $tokenId;
|
||||
|
||||
return $this->nextStep();
|
||||
}
|
||||
|
||||
private function selectTokenFromUrl(?string $selectedTokenUuid): void
|
||||
{
|
||||
if (! $selectedTokenUuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid);
|
||||
|
||||
if (! $token) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->selectedTokenUuid = $selectedTokenUuid;
|
||||
$this->selected_token_id = $token->id;
|
||||
}
|
||||
|
||||
private function getDigitalOceanToken(): string
|
||||
|
|
@ -197,27 +226,48 @@ public function nextStep()
|
|||
]);
|
||||
|
||||
try {
|
||||
$digitalOceanToken = $this->getDigitalOceanToken();
|
||||
if (! $this->selectedTokenUuid) {
|
||||
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
|
||||
|
||||
if (! $digitalOceanToken) {
|
||||
return $this->dispatch('error', 'Please select a valid DigitalOcean token.');
|
||||
if ($token) {
|
||||
return $this->redirectRoute('server.create.token', [
|
||||
'type' => 'digital-ocean',
|
||||
'token_uuid' => $token->uuid,
|
||||
], navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
$this->loadDigitalOceanData($digitalOceanToken);
|
||||
$this->current_step = 2;
|
||||
$this->loading_data = true;
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function previousStep(): void
|
||||
public function previousStep(): mixed
|
||||
{
|
||||
if ($this->selectedTokenUuid) {
|
||||
return $this->redirectRoute('server.create.type', ['type' => 'digital-ocean'], navigate: true);
|
||||
}
|
||||
|
||||
$this->current_step = 1;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function loadDigitalOceanData(string $token): void
|
||||
public function loadDigitalOceanData(): void
|
||||
{
|
||||
$token = $this->getDigitalOceanToken();
|
||||
|
||||
if (! $token) {
|
||||
$this->loading_data = false;
|
||||
$this->dispatch('error', 'Please select a valid DigitalOcean token.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->loading_data = true;
|
||||
$this->provider_data_error = null;
|
||||
|
||||
try {
|
||||
$digitalOceanService = new DigitalOceanService($token);
|
||||
|
|
@ -232,10 +282,22 @@ private function loadDigitalOceanData(string $token): 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;
|
||||
|
|
@ -37,6 +38,8 @@ class ByHetzner extends Component
|
|||
// Step 1: Token selection
|
||||
public ?int $selected_token_id = null;
|
||||
|
||||
public ?string $selectedTokenUuid = null;
|
||||
|
||||
// Step 2: Server configuration
|
||||
public array $locations = [];
|
||||
|
||||
|
|
@ -68,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;
|
||||
|
|
@ -89,11 +94,12 @@ class ByHetzner extends Component
|
|||
|
||||
public bool $from_onboarding = false;
|
||||
|
||||
public function mount()
|
||||
public function mount(?string $selectedTokenUuid = null)
|
||||
{
|
||||
try {
|
||||
$this->authorize('viewAny', CloudProviderToken::class);
|
||||
$this->loadTokens();
|
||||
$this->selectTokenFromUrl($selectedTokenUuid);
|
||||
$this->loadSavedCloudInitScripts();
|
||||
$this->server_name = generate_random_name();
|
||||
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
|
||||
|
|
@ -101,6 +107,11 @@ public function mount()
|
|||
if ($this->private_keys->count() > 0) {
|
||||
$this->private_key_id = $this->private_keys->first()->id;
|
||||
}
|
||||
|
||||
if ($this->selectedTokenUuid) {
|
||||
$this->current_step = 2;
|
||||
$this->loading_data = true;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -207,9 +218,27 @@ protected function messages(): array
|
|||
];
|
||||
}
|
||||
|
||||
public function selectToken(int $tokenId)
|
||||
public function selectToken(int $tokenId): mixed
|
||||
{
|
||||
$this->selected_token_id = $tokenId;
|
||||
|
||||
return $this->nextStep();
|
||||
}
|
||||
|
||||
private function selectTokenFromUrl(?string $selectedTokenUuid): void
|
||||
{
|
||||
if (! $selectedTokenUuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid);
|
||||
|
||||
if (! $token) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->selectedTokenUuid = $selectedTokenUuid;
|
||||
$this->selected_token_id = $token->id;
|
||||
}
|
||||
|
||||
private function validateHetznerToken(string $token): bool
|
||||
|
|
@ -244,17 +273,20 @@ public function nextStep()
|
|||
]);
|
||||
|
||||
try {
|
||||
$hetznerToken = $this->getHetznerToken();
|
||||
if (! $this->selectedTokenUuid) {
|
||||
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
|
||||
|
||||
if (! $hetznerToken) {
|
||||
return $this->dispatch('error', 'Please select a valid Hetzner token.');
|
||||
if ($token) {
|
||||
return $this->redirectRoute('server.create.token', [
|
||||
'type' => 'hetzner',
|
||||
'token_uuid' => $token->uuid,
|
||||
], navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
// Load Hetzner data
|
||||
$this->loadHetznerData($hetznerToken);
|
||||
|
||||
// Move to step 2
|
||||
// Move to step 2; provider data is loaded after initial render via wire:init.
|
||||
$this->current_step = 2;
|
||||
$this->loading_data = true;
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -262,12 +294,26 @@ public function nextStep()
|
|||
|
||||
public function previousStep()
|
||||
{
|
||||
if ($this->selectedTokenUuid) {
|
||||
return $this->redirectRoute('server.create.type', ['type' => 'hetzner'], navigate: true);
|
||||
}
|
||||
|
||||
$this->current_step = 1;
|
||||
}
|
||||
|
||||
private function loadHetznerData(string $token)
|
||||
public function loadHetznerData(): void
|
||||
{
|
||||
$token = $this->getHetznerToken();
|
||||
|
||||
if (! $token) {
|
||||
$this->loading_data = false;
|
||||
$this->dispatch('error', 'Please select a valid Hetzner token.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->loading_data = true;
|
||||
$this->provider_data_error = null;
|
||||
$this->selectedHetznerSshKeyIds = [];
|
||||
$this->selectedHetznerFirewallIds = [];
|
||||
$this->selectedHetznerNetworkIds = [];
|
||||
|
|
@ -311,10 +357,22 @@ private function loadHetznerData(string $token)
|
|||
$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'] ?? '');
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Livewire\Server\New;
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidServerIp;
|
||||
|
|
@ -84,11 +85,51 @@ protected function messages(): array
|
|||
]);
|
||||
}
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
return [
|
||||
'privateKeyCreated' => 'handlePrivateKeyCreated',
|
||||
];
|
||||
}
|
||||
|
||||
public function setPrivateKey(string $private_key_id)
|
||||
{
|
||||
$this->private_key_id = $private_key_id;
|
||||
}
|
||||
|
||||
public function generatePrivateKey(string $type): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('create', PrivateKey::class);
|
||||
|
||||
if (! in_array($type, ['ed25519', 'rsa'], true)) {
|
||||
$this->dispatch('error', 'Invalid private key type.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$keyData = PrivateKey::generateNewKeyPair($type);
|
||||
$privateKey = PrivateKey::createAndStore([
|
||||
'name' => $keyData['name'],
|
||||
'description' => $keyData['description'],
|
||||
'private_key' => $keyData['private_key'],
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
||||
$this->handlePrivateKeyCreated($privateKey->id);
|
||||
$this->dispatch('success', 'Private key created successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function handlePrivateKeyCreated($keyId): void
|
||||
{
|
||||
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
|
||||
$this->private_key_id = $keyId;
|
||||
$this->resetErrorBag('private_key_id');
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
{
|
||||
// $this->dispatch('success', 'Application settings updated!');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -33,6 +34,8 @@ class ByVultr extends Component
|
|||
|
||||
public ?int $selected_token_id = null;
|
||||
|
||||
public ?string $selectedTokenUuid = null;
|
||||
|
||||
public array $regions = [];
|
||||
|
||||
public array $plans = [];
|
||||
|
|
@ -55,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;
|
||||
|
|
@ -72,10 +77,11 @@ class ByVultr extends Component
|
|||
|
||||
public bool $from_onboarding = false;
|
||||
|
||||
public function mount(): void
|
||||
public function mount(?string $selectedTokenUuid = null): void
|
||||
{
|
||||
$this->authorize('viewAny', CloudProviderToken::class);
|
||||
$this->loadTokens();
|
||||
$this->selectTokenFromUrl($selectedTokenUuid);
|
||||
$this->loadSavedCloudInitScripts();
|
||||
$this->server_name = generate_random_name();
|
||||
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
|
||||
|
|
@ -83,6 +89,11 @@ public function mount(): void
|
|||
if ($this->private_keys->count() > 0) {
|
||||
$this->private_key_id = $this->private_keys->first()->id;
|
||||
}
|
||||
|
||||
if ($this->selectedTokenUuid) {
|
||||
$this->current_step = 2;
|
||||
$this->loading_data = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function getListeners(): array
|
||||
|
|
@ -165,9 +176,27 @@ protected function messages(): array
|
|||
];
|
||||
}
|
||||
|
||||
public function selectToken(int $tokenId): void
|
||||
public function selectToken(int $tokenId): mixed
|
||||
{
|
||||
$this->selected_token_id = $tokenId;
|
||||
|
||||
return $this->nextStep();
|
||||
}
|
||||
|
||||
private function selectTokenFromUrl(?string $selectedTokenUuid): void
|
||||
{
|
||||
if (! $selectedTokenUuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid);
|
||||
|
||||
if (! $token) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->selectedTokenUuid = $selectedTokenUuid;
|
||||
$this->selected_token_id = $token->id;
|
||||
}
|
||||
|
||||
public function nextStep(): mixed
|
||||
|
|
@ -177,14 +206,19 @@ public function nextStep(): mixed
|
|||
]);
|
||||
|
||||
try {
|
||||
$vultrToken = $this->getVultrToken();
|
||||
if (! $this->selectedTokenUuid) {
|
||||
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
|
||||
|
||||
if (! $vultrToken) {
|
||||
return $this->dispatch('error', 'Please select a valid Vultr token.');
|
||||
if ($token) {
|
||||
return $this->redirectRoute('server.create.token', [
|
||||
'type' => 'vultr',
|
||||
'token_uuid' => $token->uuid,
|
||||
], navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
$this->loadVultrData($vultrToken);
|
||||
$this->current_step = 2;
|
||||
$this->loading_data = true;
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -192,9 +226,15 @@ public function nextStep(): mixed
|
|||
return null;
|
||||
}
|
||||
|
||||
public function previousStep(): void
|
||||
public function previousStep(): mixed
|
||||
{
|
||||
if ($this->selectedTokenUuid) {
|
||||
return $this->redirectRoute('server.create.type', ['type' => 'vultr'], navigate: true);
|
||||
}
|
||||
|
||||
$this->current_step = 1;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function updatedSelectedRegion(): void
|
||||
|
|
@ -251,6 +291,29 @@ public function getSelectedServerPriceProperty(): ?string
|
|||
return '$'.number_format((float) $monthlyCost, 2);
|
||||
}
|
||||
|
||||
public function getAdvancedVultrOptionsSummaryProperty(): array
|
||||
{
|
||||
$summary = [];
|
||||
|
||||
if (count($this->selectedVultrSshKeyIds) > 0) {
|
||||
$summary[] = count($this->selectedVultrSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedVultrSshKeyIds));
|
||||
}
|
||||
|
||||
if (! $this->enable_ipv6) {
|
||||
$summary[] = 'IPv6 disabled';
|
||||
}
|
||||
|
||||
if ($this->disable_public_ipv4) {
|
||||
$summary[] = 'Public IPv4 disabled';
|
||||
}
|
||||
|
||||
if (! empty($this->cloud_init_script)) {
|
||||
$summary[] = 'cloud-init';
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function getVultrToken(): string
|
||||
{
|
||||
if ($this->selected_token_id) {
|
||||
|
|
@ -262,9 +325,19 @@ private function getVultrToken(): string
|
|||
return '';
|
||||
}
|
||||
|
||||
private function loadVultrData(string $token): void
|
||||
public function loadVultrData(): void
|
||||
{
|
||||
$token = $this->getVultrToken();
|
||||
|
||||
if (! $token) {
|
||||
$this->loading_data = false;
|
||||
$this->dispatch('error', 'Please select a valid Vultr token.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->loading_data = true;
|
||||
$this->provider_data_error = null;
|
||||
|
||||
try {
|
||||
$vultrService = new VultrService($token);
|
||||
|
|
@ -288,10 +361,22 @@ private function loadVultrData(string $token): 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);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,33 @@ public function setPrivateKey($privateKeyId)
|
|||
}
|
||||
}
|
||||
|
||||
public function generatePrivateKey(string $type): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('create', PrivateKey::class);
|
||||
|
||||
if (! in_array($type, ['ed25519', 'rsa'], true)) {
|
||||
$this->dispatch('error', 'Invalid private key type.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$keyData = PrivateKey::generateNewKeyPair($type);
|
||||
$privateKey = PrivateKey::createAndStore([
|
||||
'name' => $keyData['name'],
|
||||
'description' => $keyData['description'],
|
||||
'private_key' => $keyData['private_key'],
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
||||
$this->privateKeys = PrivateKey::ownedByCurrentTeam()->get()->where('is_git_related', false);
|
||||
$this->dispatch('copyPublicKeyToClipboard', publicKey: $privateKey->public_key);
|
||||
$this->dispatch('success', 'Private key created successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkConnection()
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -828,6 +828,7 @@ public function linkToHetzner()
|
|||
$this->hetznerSearchError = null;
|
||||
|
||||
$this->dispatch('success', 'Server successfully linked to Hetzner Cloud!');
|
||||
$this->dispatch('close-modal');
|
||||
$this->dispatch('refreshServerShow');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -958,6 +959,7 @@ public function linkToDigitalOcean()
|
|||
$this->digitalOceanSearchError = null;
|
||||
|
||||
$this->dispatch('success', 'Server successfully linked to DigitalOcean!');
|
||||
$this->dispatch('close-modal');
|
||||
$this->dispatch('refreshServerShow');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -1082,6 +1084,7 @@ public function linkToVultr()
|
|||
$this->vultrSearchError = null;
|
||||
|
||||
$this->dispatch('success', 'Server successfully linked to Vultr!');
|
||||
$this->dispatch('close-modal');
|
||||
$this->dispatch('refreshServerShow');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ public function __construct(
|
|||
public ?string $canGate = null,
|
||||
public mixed $canResource = null,
|
||||
public bool $autoDisable = true,
|
||||
public ?string $tooltip = null,
|
||||
) {
|
||||
// Handle authorization-based disabling
|
||||
if ($this->canGate && $this->canResource && $this->autoDisable) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -97,6 +97,90 @@ @keyframes lds-heart {
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes coolbox-border-track {
|
||||
0% {
|
||||
background-position: 0 0, 100% 0, 100% 100%, 0 100%;
|
||||
background-size: 35% 2px, 0 0, 0 0, 0 0;
|
||||
}
|
||||
|
||||
33% {
|
||||
background-position: 100% 0, 100% 0, 100% 100%, 0 100%;
|
||||
background-size: 35% 2px, 0 0, 0 0, 0 0;
|
||||
}
|
||||
|
||||
34% {
|
||||
background-position: 100% 0, 100% 0, 100% 100%, 0 100%;
|
||||
background-size: 0 0, 2px 35%, 0 0, 0 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 0, 100% 100%, 100% 100%, 0 100%;
|
||||
background-size: 0 0, 2px 35%, 0 0, 0 0;
|
||||
}
|
||||
|
||||
51% {
|
||||
background-position: 0 0, 100% 0, 100% 100%, 0 100%;
|
||||
background-size: 0 0, 0 0, 35% 2px, 0 0;
|
||||
}
|
||||
|
||||
84% {
|
||||
background-position: 0 0, 100% 0, 0 100%, 0 100%;
|
||||
background-size: 0 0, 0 0, 35% 2px, 0 0;
|
||||
}
|
||||
|
||||
85% {
|
||||
background-position: 0 0, 100% 0, 100% 100%, 0 100%;
|
||||
background-size: 0 0, 0 0, 0 0, 2px 35%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0 0, 100% 0, 100% 100%, 0 0;
|
||||
background-size: 0 0, 0 0, 0 0, 2px 35%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.coolbox-loading {
|
||||
@apply overflow-hidden border-transparent;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.coolbox-loading::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(var(--color-warning), var(--color-warning)),
|
||||
linear-gradient(var(--color-warning), var(--color-warning)),
|
||||
linear-gradient(var(--color-warning), var(--color-warning)),
|
||||
linear-gradient(var(--color-warning), var(--color-warning));
|
||||
background-repeat: no-repeat;
|
||||
animation: coolbox-border-track 2400ms linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.coolbox-loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: calc(0.25rem - 2px);
|
||||
background: white;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dark .coolbox-loading::after {
|
||||
background: var(--color-coolgray-100);
|
||||
}
|
||||
|
||||
.coolbox-loading > * {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Base styles
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
<svg {{ $attributes->merge(['class' => 'w-6 h-6 flex-shrink-0']) }} fill="#0080FF" role="img" viewBox="0 0 24 24"
|
||||
@php
|
||||
$iconAttributes = $attributes->has('class')
|
||||
? $attributes->class('shrink-0')
|
||||
: $attributes->merge(['class' => 'size-6 shrink-0']);
|
||||
@endphp
|
||||
|
||||
<svg {{ $iconAttributes }} fill="#0080FF" role="img" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<title>DigitalOcean</title>
|
||||
<path
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 534 B After Width: | Height: | Size: 658 B |
|
|
@ -1,4 +1,4 @@
|
|||
@if ($authDisabled)
|
||||
@if ($authDisabled || filled($tooltip))
|
||||
<span class="relative inline-flex"
|
||||
x-data="{ visible: false, _t: null }"
|
||||
@mouseenter="_t = setTimeout(() => {
|
||||
|
|
@ -39,9 +39,9 @@
|
|||
@endif
|
||||
@endif
|
||||
</button>
|
||||
@if ($authDisabled)
|
||||
@if ($authDisabled || filled($tooltip))
|
||||
<div x-ref="tip" x-show="visible" x-cloak class="auth-tooltip">
|
||||
You do not have permission to perform this action.
|
||||
{{ $tooltip ?: 'You do not have permission to perform this action.' }}
|
||||
</div>
|
||||
</span>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -1,6 +1,47 @@
|
|||
<div x-data="{ open: false }" @click.stop="open = !open" @click.outside="open = false"
|
||||
{{ $attributes->merge(['class' => 'group relative inline-block align-middle']) }}>
|
||||
<div class="info-helper">
|
||||
<div x-data="{
|
||||
open: false,
|
||||
pinned: false,
|
||||
style: '',
|
||||
show(pinned = false) {
|
||||
this.pinned = pinned;
|
||||
this.open = true;
|
||||
this.$nextTick(() => this.position());
|
||||
},
|
||||
hide() {
|
||||
if (!this.pinned) {
|
||||
this.open = false;
|
||||
}
|
||||
},
|
||||
close() {
|
||||
this.pinned = false;
|
||||
this.open = false;
|
||||
},
|
||||
position() {
|
||||
const trigger = this.$refs.trigger;
|
||||
const popup = this.$refs.popup;
|
||||
|
||||
if (!trigger || !popup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const popupRect = popup.getBoundingClientRect();
|
||||
const padding = 8;
|
||||
let top = triggerRect.bottom + padding;
|
||||
let left = triggerRect.right - popupRect.width;
|
||||
|
||||
if (top + popupRect.height > window.innerHeight - padding) {
|
||||
top = triggerRect.top - popupRect.height - padding;
|
||||
}
|
||||
|
||||
left = Math.min(Math.max(padding, left), window.innerWidth - popupRect.width - padding);
|
||||
top = Math.max(padding, top);
|
||||
|
||||
this.style = `top: ${top}px; left: ${left}px;`;
|
||||
}
|
||||
}" @click.outside="close" @keydown.window.escape="close" @resize.window="open && position()" @scroll.window="open && position()"
|
||||
{{ $attributes->merge(['class' => 'inline-block align-middle']) }}>
|
||||
<div x-ref="trigger" class="info-helper" @mouseenter="show(false)" @mouseleave="hide" @click.stop="open && pinned ? close() : show(true)">
|
||||
@isset($icon)
|
||||
{{ $icon }}
|
||||
@else
|
||||
|
|
@ -9,11 +50,13 @@
|
|||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
@endisset
|
||||
|
||||
</div>
|
||||
<div class="info-helper-popup" :class="{ 'block': open }">
|
||||
<div class="p-4">
|
||||
{!! $helper !!}
|
||||
<template x-teleport="body">
|
||||
<div x-ref="popup" x-show="open" x-cloak :style="style"
|
||||
class="fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))] rounded-sm border border-neutral-300 bg-neutral-200 text-xs text-neutral-700 shadow-lg whitespace-normal break-words dark:border-coolgray-500 dark:bg-coolgray-400 dark:text-neutral-300">
|
||||
<div class="p-4">
|
||||
{!! $helper !!}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
'buttonFullWidth' => false,
|
||||
'customButton' => null,
|
||||
'disabled' => false,
|
||||
'disabledTooltip' => null,
|
||||
'authDisabled' => false,
|
||||
'dispatchAction' => false,
|
||||
'submitAction' => 'delete',
|
||||
|
|
@ -156,11 +157,11 @@
|
|||
@else
|
||||
@if ($disabled)
|
||||
@if ($buttonFullWidth)
|
||||
<x-forms.button class="w-full" isError disabled :authDisabled="$authDisabled" wire:target>
|
||||
<x-forms.button class="w-full" isError disabled :authDisabled="$authDisabled" :tooltip="$disabledTooltip" wire:target>
|
||||
{{ $buttonTitle }}
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button isError disabled :authDisabled="$authDisabled" wire:target>
|
||||
<x-forms.button isError disabled :authDisabled="$authDisabled" :tooltip="$disabledTooltip" wire:target>
|
||||
{{ $buttonTitle }}
|
||||
</x-forms.button>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
'content' => null,
|
||||
'closeOutside' => true,
|
||||
'isFullWidth' => false,
|
||||
'wireIgnore' => true,
|
||||
])
|
||||
|
||||
@php
|
||||
|
|
@ -17,7 +18,7 @@
|
|||
<div x-data="{ modalOpen: false }"
|
||||
x-init="$watch('modalOpen', value => { if (!value) { $wire.dispatch('modalClosed') } })"
|
||||
:class="{ 'z-40': modalOpen }" @keydown.window.escape="modalOpen=false"
|
||||
class="relative w-auto h-auto" @close-modal.window="modalOpen=false" wire:ignore>
|
||||
class="relative w-auto h-auto" @close-modal.window="modalOpen=false" @if ($wireIgnore) wire:ignore @endif>
|
||||
@if ($content)
|
||||
<div @click="modalOpen=true">
|
||||
{{ $content }}
|
||||
|
|
|
|||
|
|
@ -85,19 +85,14 @@ class="flex items-center justify-center size-4 text-black dark:text-white rounde
|
|||
<h3>Servers</h3>
|
||||
@can('create', App\Models\Server::class)
|
||||
@if ($servers->count() > 0 && $privateKeys->count() > 0)
|
||||
<x-modal-input buttonTitle="Add" title="New Server" :closeOutside="false">
|
||||
<x-slot:content>
|
||||
<button
|
||||
class="flex items-center justify-center size-4 text-black dark:text-white rounded hover:bg-coolgray-400 dark:hover:bg-coolgray-300 cursor-pointer">
|
||||
<svg class="size-3" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<livewire:server.create />
|
||||
</x-modal-input>
|
||||
<a href="{{ route('server.create') }}" {{ wireNavigate() }}
|
||||
class="flex items-center justify-center size-4 text-black dark:text-white rounded hover:bg-coolgray-400 dark:hover:bg-coolgray-300 cursor-pointer">
|
||||
<svg class="size-3" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</a>
|
||||
@endif
|
||||
@endcan
|
||||
</div>
|
||||
|
|
@ -154,9 +149,9 @@ class="flex items-center justify-center size-4 text-black dark:text-white rounde
|
|||
<div class='font-bold dark:text-warning'>No servers found.</div>
|
||||
@can('create', App\Models\Server::class)
|
||||
<div class="flex items-center gap-1">
|
||||
<x-modal-input buttonTitle="Add" title="New Server" :closeOutside="false">
|
||||
<livewire:server.create />
|
||||
</x-modal-input> your first server
|
||||
<a href="{{ route('server.create') }}" {{ wireNavigate() }}>
|
||||
<x-forms.button>Add</x-forms.button>
|
||||
</a> your first server
|
||||
or
|
||||
go to the <a class="underline dark:text-white"
|
||||
href="{{ route('onboarding') }}"
|
||||
|
|
|
|||
|
|
@ -245,6 +245,10 @@
|
|||
if (value) {
|
||||
// Close search modal first
|
||||
this.closeModal();
|
||||
if (value === 'server') {
|
||||
window.location.href = '/servers/new';
|
||||
return;
|
||||
}
|
||||
// Open the specific resource modal after a short delay
|
||||
setTimeout(() => {
|
||||
this.$dispatch('open-create-modal-' + value);
|
||||
|
|
@ -705,9 +709,9 @@ class="search-result-item w-full text-left block px-4 py-3 hover:bg-neutral-100
|
|||
</div>
|
||||
@else
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 rounded-lg bg-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
|
||||
class="flex-shrink-0 w-10 h-10 rounded-full bg-warning/20 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-warning-600 dark:text-warning-400" fill="none"
|
||||
class="h-6 w-6 text-warning" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 4v16m8-8H4" />
|
||||
|
|
@ -821,9 +825,9 @@ class="search-result-item w-full text-left block px-4 py-3 hover:bg-neutral-100
|
|||
</template>
|
||||
<template x-if="!item.logo">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 rounded-lg bg-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
|
||||
class="flex-shrink-0 w-10 h-10 rounded-full bg-warning/20 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-warning-600 dark:text-warning-400"
|
||||
class="h-6 w-6 text-warning"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
|
|
@ -937,46 +941,6 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
|
|||
</template>
|
||||
</div>
|
||||
|
||||
<div x-data="{ modalOpen: false }" @open-create-modal-server.window="modalOpen = true"
|
||||
@keydown.window.escape="modalOpen=false" class="relative w-auto h-auto">
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen" x-init="$watch('modalOpen', value => {
|
||||
if (value) {
|
||||
setTimeout(() => {
|
||||
const firstInput = $el.querySelector('input, textarea, select');
|
||||
if (firstInput) firstInput.focus();
|
||||
}, 200);
|
||||
}
|
||||
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||
class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w-[36rem] max-w-fit bg-white border-neutral-200 dark:bg-base px-6 dark:border-coolgray-300">
|
||||
<div class="flex items-center justify-between pb-3">
|
||||
<h3 class="text-2xl font-bold">New Server</h3>
|
||||
<button @click="modalOpen=false"
|
||||
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||
<svg class="w-5 h-5" 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex items-center justify-center w-auto">
|
||||
<livewire:server.create key="create-modal-server" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-data="{ modalOpen: false }" @open-create-modal-team.window="modalOpen = true"
|
||||
@keydown.window.escape="modalOpen=false" class="relative w-auto h-auto">
|
||||
<template x-teleport="body">
|
||||
|
|
|
|||
|
|
@ -8,28 +8,28 @@
|
|||
@forelse ($private_keys as $key)
|
||||
@if ($private_key_id == $key->id)
|
||||
<div class="gap-2 py-4 cursor-pointer group coolbox"
|
||||
wire:click="setPrivateKey('{{ $key->id }}')" wire:key="{{ $key->id }}">
|
||||
wire:click="setPrivateKey('{{ $key->id }}')"
|
||||
wire:loading.class="coolbox-loading"
|
||||
wire:target="setPrivateKey('{{ $key->id }}')" wire:key="{{ $key->id }}">
|
||||
<div class="flex flex-col mx-6">
|
||||
<div class="box-title">
|
||||
{{ $key->name }}
|
||||
</div>
|
||||
<div class="box-description">
|
||||
{{ $key->description }}</div>
|
||||
<span wire:target="loadRepositories" wire:loading.delay
|
||||
class="loading loading-xs dark:text-warning loading-spinner"></span>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="gap-2 py-4 cursor-pointer group coolbox"
|
||||
wire:click="setPrivateKey('{{ $key->id }}')" wire:key="{{ $key->id }}">
|
||||
wire:click="setPrivateKey('{{ $key->id }}')"
|
||||
wire:loading.class="coolbox-loading"
|
||||
wire:target="setPrivateKey('{{ $key->id }}')" wire:key="{{ $key->id }}">
|
||||
<div class="flex flex-col mx-6">
|
||||
<div class="box-title">
|
||||
{{ $key->name }}
|
||||
</div>
|
||||
<div class="box-description">
|
||||
{{ $key->description }}</div>
|
||||
<span wire:target="loadRepositories" wire:loading.delay
|
||||
class="loading loading-xs dark:text-warning loading-spinner"></span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@
|
|||
<div class="flex">
|
||||
<div class="w-full gap-2 py-4 group coolbox"
|
||||
wire:click.prevent="loadRepositories({{ $ghapp->id }})"
|
||||
wire:loading.class="coolbox-loading"
|
||||
wire:target="loadRepositories({{ $ghapp->id }})"
|
||||
wire:key="{{ $ghapp->id }}">
|
||||
<div class="flex mr-4">
|
||||
<div class="flex flex-col mx-6">
|
||||
|
|
@ -36,9 +38,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<x-loading wire:loading wire:target="loadRepositories({{ $ghapp->id }})" />
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -75,7 +75,9 @@ class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="loading">Loading...</div>
|
||||
<div x-show="loading" class="flex items-center justify-center py-8">
|
||||
<x-loading text="Loading resources..." />
|
||||
</div>
|
||||
<div x-show="!loading" class="flex flex-col gap-4 py-4">
|
||||
<h2 x-show="filteredGitBasedApplications.length > 0">Applications</h2>
|
||||
<div x-show="filteredGitBasedApplications.length > 0 || filteredDockerBasedApplications.length > 0"
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
||||
|
|
@ -24,27 +27,19 @@
|
|||
target='_blank' class='underline dark:text-white'>{{ $provider === 'digitalocean' ? 'DigitalOcean' : ucfirst($provider) }} Console</a>.
|
||||
@if ($provider === 'hetzner')
|
||||
Choose Project → Security → API Tokens.
|
||||
<br><br>
|
||||
Don't have a Hetzner account? <a href='https://coolify.io/hetzner' target='_blank'
|
||||
class='underline dark:text-white'>Sign up here</a>
|
||||
<br>
|
||||
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
|
||||
and gives you €20)</span>
|
||||
@endif
|
||||
@if ($provider === 'digitalocean')
|
||||
Generate New Token.
|
||||
@endif
|
||||
@if ($provider === 'vultr')
|
||||
Open Account → API Access.
|
||||
<br><br>
|
||||
Don't have a Vultr account? <a href='https://www.vultr.com/?ref=9911335' target='_blank'
|
||||
class='underline dark:text-white'>Sign up here</a>
|
||||
<br>
|
||||
<span class="text-xs">Coolify's affiliate link, only new accounts - supports us</span>
|
||||
@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">
|
||||
|
|
@ -58,6 +53,10 @@ class='underline dark:text-white'>Sign up here</a>
|
|||
<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"
|
||||
|
|
@ -68,27 +67,19 @@ class='underline dark:text-white'>Sign up here</a>
|
|||
target='_blank' class='underline dark:text-white'>{{ $provider === 'digitalocean' ? 'DigitalOcean' : ucfirst($provider) }} Console</a>.
|
||||
@if ($provider === 'hetzner')
|
||||
Choose Project → Security → API Tokens.
|
||||
<br><br>
|
||||
Don't have a Hetzner account? <a href='https://coolify.io/hetzner' target='_blank'
|
||||
class='underline dark:text-white'>Sign up here</a>
|
||||
<br>
|
||||
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
|
||||
and gives you €20)</span>
|
||||
@endif
|
||||
@if ($provider === 'digitalocean')
|
||||
Generate New Token.
|
||||
@endif
|
||||
@if ($provider === 'vultr')
|
||||
Open Account → API Access.
|
||||
<br><br>
|
||||
Don't have a Vultr account? <a href='https://www.vultr.com/?ref=9911335' target='_blank'
|
||||
class='underline dark:text-white'>Sign up here</a>
|
||||
<br>
|
||||
<span class="text-xs">Coolify's affiliate link, only new accounts - supports us</span>
|
||||
@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,15 +5,21 @@
|
|||
<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>
|
||||
@if (data_get($private_key, 'id') > 0)
|
||||
@can('delete', $private_key)
|
||||
<x-modal-confirmation title="Confirm Private Key Deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="delete({{ $private_key->id }})" :actions="[
|
||||
submitAction="delete({{ $private_key->id }})" :disabled="$isInUse" :disabledTooltip="$deleteDisabledReason" :actions="[
|
||||
'This private key will be permanently deleted.',
|
||||
'All servers connected to this private key will stop working.',
|
||||
'Any git app using this private key will stop working.',
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
29
resources/views/livewire/server/create-page.blade.php
Normal file
29
resources/views/livewire/server/create-page.blade.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<div class="flex flex-col gap-4">
|
||||
<x-slot:title>
|
||||
{{ $title }} | Coolify
|
||||
</x-slot>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1>{{ $title }}</h1>
|
||||
@if ($type)
|
||||
<a href="{{ $token_uuid ? route('server.create.type', ['type' => $type]) : route('server.create') }}" {{ wireNavigate() }}>
|
||||
<x-forms.button>Back</x-forms.button>
|
||||
</a>
|
||||
@if (! $token_uuid && $hasProviderTokens && $tokenProvider)
|
||||
@can('create', App\Models\CloudProviderToken::class)
|
||||
<x-modal-input buttonTitle="+ New Token" title="Add {{ $tokenProviderName }} Token" isHighlightedButton>
|
||||
<livewire:security.cloud-provider-token-form :modal_mode="true" :provider="$tokenProvider"
|
||||
wire:key="new-server-header-token-{{ $tokenProvider }}" />
|
||||
</x-modal-input>
|
||||
@endcan
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
<div class="subtitle">Add a server to deploy your applications and databases.</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<livewire:server.create :selected-type="$type" :selected-token-uuid="$token_uuid" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,78 +1,117 @@
|
|||
<div class="w-full">
|
||||
<div class="flex flex-col gap-4">
|
||||
@can('viewAny', App\Models\CloudProviderToken::class)
|
||||
<div>
|
||||
<x-modal-input title="Connect a Hetzner Server">
|
||||
<x-slot:content>
|
||||
<div class="relative gap-2 cursor-pointer coolbox group">
|
||||
<div class="flex items-center gap-4 mx-6">
|
||||
<svg class="w-10 h-10 flex-shrink-0" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
@if (!$selectedType)
|
||||
<div class="flex flex-col items-center justify-center gap-3 sm:gap-6 sm:min-h-[calc(100vh-12rem)]">
|
||||
<div class="mx-auto flex w-full max-w-7xl flex-col gap-3 sm:grid sm:grid-cols-2 sm:gap-6 xl:grid-cols-4">
|
||||
@can('viewAny', App\Models\CloudProviderToken::class)
|
||||
<a href="{{ route('server.create.type', ['type' => 'hetzner']) }}" aria-label="Choose Hetzner"
|
||||
class="box-without-bg flex-col gap-3 sm:gap-6 p-3 sm:p-6 h-full cursor-pointer transition-all duration-200 hover:border-coollabs dark:hover:border-warning focus-visible:border-coollabs dark:focus-visible:border-warning focus-visible:outline-none"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex flex-col gap-2 sm:gap-4 text-left h-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<svg class="size-9 sm:size-14" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" fill="#D50C2D" rx="8" />
|
||||
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z" fill="white" />
|
||||
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z"
|
||||
fill="white" />
|
||||
</svg>
|
||||
<div class="flex flex-col justify-center flex-1">
|
||||
<div class="box-title">Connect a Hetzner Server</div>
|
||||
<div class="box-description">
|
||||
Deploy servers directly from your Hetzner Cloud account
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-[10px] sm:px-2 sm:py-1 sm:text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
|
||||
Provider
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm sm:text-xl font-bold mb-1 sm:mb-2">Hetzner</h3>
|
||||
<p class="hidden sm:block text-sm dark:text-neutral-400">
|
||||
Deploy servers directly from your Hetzner Cloud account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<livewire:server.new.by-hetzner :private_keys="$private_keys" :limit_reached="$limit_reached" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div>
|
||||
<x-modal-input title="Connect a Vultr Server">
|
||||
<x-slot:content>
|
||||
<div class="relative gap-2 cursor-pointer coolbox group">
|
||||
<div class="flex items-center gap-4 mx-6">
|
||||
<svg class="w-10 h-10 flex-shrink-0" viewBox="0 0 200 200"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<a href="{{ route('server.create.type', ['type' => 'vultr']) }}" aria-label="Choose Vultr"
|
||||
class="box-without-bg flex-col gap-3 sm:gap-6 p-3 sm:p-6 h-full cursor-pointer transition-all duration-200 hover:border-coollabs dark:hover:border-warning focus-visible:border-coollabs dark:focus-visible:border-warning focus-visible:outline-none"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex flex-col gap-2 sm:gap-4 text-left h-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<svg class="size-9 sm:size-14" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" fill="#007BFC" rx="8" />
|
||||
<path d="M42 46 H73 L100 127 L127 46 H158 L114 154 H86 Z" fill="white" />
|
||||
</svg>
|
||||
<div class="flex flex-col justify-center flex-1">
|
||||
<div class="box-title">Connect a Vultr Server</div>
|
||||
<div class="box-description">
|
||||
Deploy servers directly from your Vultr account
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-[10px] sm:px-2 sm:py-1 sm:text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
|
||||
Provider
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm sm:text-xl font-bold mb-1 sm:mb-2">Vultr</h3>
|
||||
<p class="hidden sm:block text-sm dark:text-neutral-400">
|
||||
Deploy servers directly from your Vultr account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<livewire:server.new.by-vultr :private_keys="$private_keys" :limit_reached="$limit_reached" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="border-t dark:border-coolgray-300 my-4"></div>
|
||||
|
||||
<div>
|
||||
<x-modal-input title="Connect a DigitalOcean Droplet">
|
||||
<x-slot:content>
|
||||
<div class="relative gap-2 cursor-pointer coolbox group">
|
||||
<div class="flex items-center gap-4 mx-6">
|
||||
<x-digital-ocean-icon class="w-10 h-10 flex-shrink-0" />
|
||||
<div class="flex flex-col justify-center flex-1">
|
||||
<div class="box-title">Connect a DigitalOcean Droplet</div>
|
||||
<div class="box-description">
|
||||
Deploy servers directly from your DigitalOcean account
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ route('server.create.type', ['type' => 'digital-ocean']) }}"
|
||||
aria-label="Choose DigitalOcean"
|
||||
class="box-without-bg flex-col gap-3 sm:gap-6 p-3 sm:p-6 h-full cursor-pointer transition-all duration-200 hover:border-coollabs dark:hover:border-warning focus-visible:border-coollabs dark:focus-visible:border-warning focus-visible:outline-none"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex flex-col gap-2 sm:gap-4 text-left h-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<x-digital-ocean-icon class="size-9 sm:size-14" />
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-[10px] sm:px-2 sm:py-1 sm:text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
|
||||
Provider
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm sm:text-xl font-bold mb-1 sm:mb-2">DigitalOcean</h3>
|
||||
<p class="hidden sm:block text-sm dark:text-neutral-400">
|
||||
Deploy droplets directly from your DigitalOcean account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<livewire:server.new.by-digital-ocean :private_keys="$private_keys" :limit_reached="$limit_reached" />
|
||||
</x-modal-input>
|
||||
</a>
|
||||
@endcan
|
||||
|
||||
<a href="{{ route('server.create.type', ['type' => 'manual']) }}" aria-label="Choose Manual"
|
||||
class="box-without-bg flex-col gap-3 sm:gap-6 p-3 sm:p-6 h-full cursor-pointer transition-all duration-200 hover:border-coollabs dark:hover:border-warning focus-visible:border-coollabs dark:focus-visible:border-warning focus-visible:outline-none"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex flex-col gap-2 sm:gap-4 text-left h-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<svg class="size-9 sm:size-14" 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="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-[10px] sm:px-2 sm:py-1 sm:text-xs font-bold uppercase tracking-wide bg-neutral-100 dark:bg-coolgray-300 dark:text-neutral-400 rounded">
|
||||
Manual
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm sm:text-xl font-bold mb-1 sm:mb-2">Manual</h3>
|
||||
<p class="hidden sm:block text-sm dark:text-neutral-400">
|
||||
Add any reachable server by IP address or domain.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="border-t dark:border-coolgray-300 my-4"></div>
|
||||
@endcan
|
||||
|
||||
<div>
|
||||
<h3 class="pb-2">Add Server by IP Address</h3>
|
||||
<livewire:server.new.by-ip :private_keys="$private_keys" :limit_reached="$limit_reached" />
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-col gap-4">
|
||||
@if ($selectedType === 'hetzner')
|
||||
<livewire:server.new.by-hetzner :private_keys="$private_keys" :limit_reached="$limit_reached"
|
||||
:selected-token-uuid="$selectedTokenUuid" wire:key="new-server-hetzner-{{ $selectedTokenUuid ?? 'select' }}" />
|
||||
@elseif ($selectedType === 'vultr')
|
||||
<livewire:server.new.by-vultr :private_keys="$private_keys" :limit_reached="$limit_reached"
|
||||
:selected-token-uuid="$selectedTokenUuid" wire:key="new-server-vultr-{{ $selectedTokenUuid ?? 'select' }}" />
|
||||
@elseif ($selectedType === 'digital-ocean')
|
||||
<livewire:server.new.by-digital-ocean :private_keys="$private_keys" :limit_reached="$limit_reached"
|
||||
:selected-token-uuid="$selectedTokenUuid" wire:key="new-server-digital-ocean-{{ $selectedTokenUuid ?? 'select' }}" />
|
||||
@else
|
||||
<livewire:server.new.by-ip :private_keys="$private_keys" :limit_reached="$limit_reached"
|
||||
key="new-server-manual" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
<div class="flex items-center gap-2">
|
||||
<h1>Servers</h1>
|
||||
@can('createAnyResource')
|
||||
<x-modal-input buttonTitle="+ Add" title="New Server" :closeOutside="false">
|
||||
<livewire:server.create />
|
||||
</x-modal-input>
|
||||
<a href="{{ route('server.create') }}" {{ wireNavigate() }}>
|
||||
<x-forms.button>+ Add</x-forms.button>
|
||||
</a>
|
||||
@endcan
|
||||
</div>
|
||||
<div class="subtitle">All your servers are here.</div>
|
||||
|
|
|
|||
|
|
@ -4,42 +4,76 @@
|
|||
@else
|
||||
@if ($current_step === 1)
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Don't have a DigitalOcean account? <a href="https://coolify.io/digitalocean" target="_blank"
|
||||
class="underline dark:text-white">Sign up here</a>.
|
||||
<span class="text-xs">Coolify's referral link - it supports both of us.</span>
|
||||
</div>
|
||||
@if ($available_tokens->count() > 0)
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1">
|
||||
<x-forms.select label="Select DigitalOcean Token" id="selected_token_id"
|
||||
wire:change="selectToken($event.target.value)" required>
|
||||
<option value="">Select a saved token...</option>
|
||||
@foreach ($available_tokens as $token)
|
||||
<option value="{{ $token->id }}">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
@foreach ($available_tokens as $token)
|
||||
<a class="coolbox group text-left" wire:key="digital-ocean-token-{{ $token->id }}"
|
||||
href="{{ route('server.create.token', ['type' => 'digital-ocean', 'token_uuid' => $token->uuid]) }}" {{ wireNavigate() }}>
|
||||
<div class="flex flex-col justify-center mx-6">
|
||||
<div class="box-title">
|
||||
{{ $token->name ?? 'DigitalOcean Token' }}
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<x-forms.button canGate="create" :canResource="App\Models\Server::class" wire:click="nextStep"
|
||||
:disabled="!$selected_token_id">
|
||||
Continue
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-description">
|
||||
Use this token to create a DigitalOcean Droplet.
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="w-full max-w-2xl">
|
||||
<x-modal-input title="Add DigitalOcean Token">
|
||||
<x-slot:content>
|
||||
<div class="coolbox group cursor-pointer">
|
||||
<div class="flex items-center gap-4 mx-6">
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-coollabs/10 text-coollabs dark:bg-warning/20 dark:text-warning">
|
||||
<svg class="size-6" 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="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center">
|
||||
<div class="box-title">Add a new token</div>
|
||||
<div class="box-description">
|
||||
Add a DigitalOcean API token to create Droplets from your account.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="digitalocean"
|
||||
wire:key="new-server-empty-token-digitalocean" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-sm dark:text-neutral-500">OR</div>
|
||||
@endif
|
||||
|
||||
<x-modal-input isFullWidth
|
||||
buttonTitle="{{ $available_tokens->count() > 0 ? '+ Add New Token' : 'Add DigitalOcean Token' }}"
|
||||
title="Add DigitalOcean Token">
|
||||
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="digitalocean" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
@elseif ($current_step === 2)
|
||||
<div wire:init="loadDigitalOceanData">
|
||||
@if ($loading_data)
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||
<p class="mt-4 text-sm dark:text-neutral-400">Loading DigitalOcean data...</p>
|
||||
<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
|
||||
|
|
@ -211,17 +245,13 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
|
|||
</div>
|
||||
</x-dropdown>
|
||||
|
||||
<div class="flex gap-2 justify-between">
|
||||
<x-forms.button type="button" wire:click="previousStep">
|
||||
Back
|
||||
</x-forms.button>
|
||||
<x-forms.button isHighlighted canGate="create" :canResource="App\Models\Server::class" type="submit"
|
||||
:disabled="!$private_key_id">
|
||||
Buy & Create Server{{ $this->selectedDropletPrice ? ' (' . $this->selectedDropletPrice . '/mo)' : '' }}
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<x-forms.button class="w-full" isHighlighted canGate="create" :canResource="App\Models\Server::class"
|
||||
type="submit" :disabled="!$private_key_id">
|
||||
Buy & Create Server{{ $this->selectedDropletPrice ? ' (' . $this->selectedDropletPrice . '/mo)' : '' }}
|
||||
</x-forms.button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,42 +4,76 @@
|
|||
@else
|
||||
@if ($current_step === 1)
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Don't have a Hetzner account? <a href="https://coolify.io/hetzner" target="_blank"
|
||||
class="underline dark:text-white">Sign up here</a>.
|
||||
<span class="text-xs">Coolify's affiliate link, only for new accounts - it supports both of us.</span>
|
||||
</div>
|
||||
@if ($available_tokens->count() > 0)
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1">
|
||||
<x-forms.select label="Select Hetzner Token" id="selected_token_id"
|
||||
wire:change="selectToken($event.target.value)" required>
|
||||
<option value="">Select a saved token...</option>
|
||||
@foreach ($available_tokens as $token)
|
||||
<option value="{{ $token->id }}">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
@foreach ($available_tokens as $token)
|
||||
<a class="coolbox group text-left" wire:key="hetzner-token-{{ $token->id }}"
|
||||
href="{{ route('server.create.token', ['type' => 'hetzner', 'token_uuid' => $token->uuid]) }}" {{ wireNavigate() }}>
|
||||
<div class="flex flex-col justify-center mx-6">
|
||||
<div class="box-title">
|
||||
{{ $token->name ?? 'Hetzner Token' }}
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<x-forms.button canGate="create" :canResource="App\Models\Server::class" wire:click="nextStep"
|
||||
:disabled="!$selected_token_id">
|
||||
Continue
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-description">
|
||||
Use this token to create a Hetzner server.
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="w-full max-w-2xl">
|
||||
<x-modal-input title="Add Hetzner Token">
|
||||
<x-slot:content>
|
||||
<div class="coolbox group cursor-pointer">
|
||||
<div class="flex items-center gap-4 mx-6">
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-coollabs/10 text-coollabs dark:bg-warning/20 dark:text-warning">
|
||||
<svg class="size-6" 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="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center">
|
||||
<div class="box-title">Add a new token</div>
|
||||
<div class="box-description">
|
||||
Add a Hetzner API token to create servers from your account.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner"
|
||||
wire:key="new-server-empty-token-hetzner" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-sm dark:text-neutral-500">OR</div>
|
||||
@endif
|
||||
|
||||
<x-modal-input isFullWidth
|
||||
buttonTitle="{{ $available_tokens->count() > 0 ? '+ Add New Token' : 'Add Hetzner Token' }}"
|
||||
title="Add Hetzner Token">
|
||||
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
@elseif ($current_step === 2)
|
||||
<div wire:init="loadHetznerData">
|
||||
@if ($loading_data)
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||
<p class="mt-4 text-sm dark:text-neutral-400">Loading Hetzner data...</p>
|
||||
<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
|
||||
|
|
@ -248,17 +282,13 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
|
|||
</div>
|
||||
</x-dropdown>
|
||||
|
||||
<div class="flex gap-2 justify-between">
|
||||
<x-forms.button type="button" wire:click="previousStep">
|
||||
Back
|
||||
</x-forms.button>
|
||||
<x-forms.button isHighlighted canGate="create" :canResource="App\Models\Server::class" type="submit"
|
||||
:disabled="!$private_key_id">
|
||||
Buy & Create Server{{ $this->selectedServerPrice ? ' (' . $this->selectedServerPrice . '/mo)' : '' }}
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,16 +16,65 @@
|
|||
<div class="text-xs dark:text-warning text-coollabs ">Non-root user is experimental: <a
|
||||
class="font-bold underline" target="_blank"
|
||||
href="https://coolify.io/docs/knowledge-base/server/non-root-user">docs</a>.</div>
|
||||
<x-forms.select label="Private Key" id="private_key_id">
|
||||
<option disabled>Select a private key</option>
|
||||
@foreach ($private_keys as $key)
|
||||
@if ($loop->first)
|
||||
<option selected value="{{ $key->id }}">{{ $key->name }}</option>
|
||||
@else
|
||||
<option value="{{ $key->id }}">{{ $key->name }}</option>
|
||||
@endif
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="grow">
|
||||
<x-forms.select label="Private Key" id="private_key_id">
|
||||
<option disabled>Select a private key</option>
|
||||
@foreach ($private_keys as $key)
|
||||
<option value="{{ $key->id }}">{{ $key->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
@can('create', App\Models\PrivateKey::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>
|
||||
|
||||
<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 right-0 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 :modal_mode="true" from="server" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
<div class="">
|
||||
<x-forms.checkbox instantSave type="checkbox" id="is_build_server"
|
||||
helper="Build servers are used to build your applications, so you cannot deploy applications to it."
|
||||
|
|
|
|||
|
|
@ -4,42 +4,76 @@
|
|||
@else
|
||||
@if ($current_step === 1)
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Don't have a Vultr account? <a href="https://coolify.io/vultr" target="_blank"
|
||||
class="underline dark:text-white">Sign up here</a>.
|
||||
<span class="text-xs">Coolify's affiliate link - it supports both of us.</span>
|
||||
</div>
|
||||
@if ($available_tokens->count() > 0)
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1">
|
||||
<x-forms.select label="Select Vultr Token" id="selected_token_id"
|
||||
wire:change="selectToken($event.target.value)" required>
|
||||
<option value="">Select a saved token...</option>
|
||||
@foreach ($available_tokens as $token)
|
||||
<option value="{{ $token->id }}">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
@foreach ($available_tokens as $token)
|
||||
<a class="coolbox group text-left" wire:key="vultr-token-{{ $token->id }}"
|
||||
href="{{ route('server.create.token', ['type' => 'vultr', 'token_uuid' => $token->uuid]) }}" {{ wireNavigate() }}>
|
||||
<div class="flex flex-col justify-center mx-6">
|
||||
<div class="box-title">
|
||||
{{ $token->name ?? 'Vultr Token' }}
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<x-forms.button canGate="create" :canResource="App\Models\Server::class" wire:click="nextStep"
|
||||
:disabled="!$selected_token_id">
|
||||
Continue
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-description">
|
||||
Use this token to create a Vultr server.
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="w-full max-w-2xl">
|
||||
<x-modal-input title="Add Vultr Token">
|
||||
<x-slot:content>
|
||||
<div class="coolbox group cursor-pointer">
|
||||
<div class="flex items-center gap-4 mx-6">
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-coollabs/10 text-coollabs dark:bg-warning/20 dark:text-warning">
|
||||
<svg class="size-6" 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="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center">
|
||||
<div class="box-title">Add a new token</div>
|
||||
<div class="box-description">
|
||||
Add a Vultr API token to create servers from your account.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="vultr"
|
||||
wire:key="new-server-empty-token-vultr" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-sm dark:text-neutral-500">OR</div>
|
||||
@endif
|
||||
|
||||
<x-modal-input isFullWidth
|
||||
buttonTitle="{{ $available_tokens->count() > 0 ? '+ Add New Token' : 'Add Vultr Token' }}"
|
||||
title="Add Vultr Token">
|
||||
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="vultr" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
@elseif ($current_step === 2)
|
||||
<div wire:init="loadVultrData">
|
||||
@if ($loading_data)
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||
<p class="mt-4 text-sm dark:text-neutral-400">Loading Vultr data...</p>
|
||||
<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
|
||||
|
|
@ -125,68 +159,82 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
|
|||
@endif
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-forms.datalist label="Additional SSH Keys (from Vultr)" id="selectedVultrSshKeyIds"
|
||||
helper="Select existing SSH keys from your Vultr account to add to this server. The Coolify SSH key will be automatically added."
|
||||
:multiple="true" :disabled="count($vultrSshKeys) === 0" :placeholder="count($vultrSshKeys) > 0 ? 'Search and select SSH keys...' : 'No SSH keys found in Vultr account'">
|
||||
@foreach ($vultrSshKeys as $sshKey)
|
||||
<option value="{{ $sshKey['id'] }}">
|
||||
{{ $sshKey['name'] }} - {{ substr($sshKey['id'], 0, 20) }}
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium">Network Configuration</label>
|
||||
<div class="flex gap-4">
|
||||
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
|
||||
helper="Enable public IPv6 address for this server" />
|
||||
<x-forms.checkbox id="disable_public_ipv4" label="Disable public IPv4"
|
||||
helper="Disable the default public IPv4 address" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
|
||||
@if ($saved_cloud_init_scripts->count() > 0)
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
|
||||
<option value="">Load saved script...</option>
|
||||
@foreach ($saved_cloud_init_scripts as $script)
|
||||
<option value="{{ $script->id }}">{{ $script->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
<x-forms.button type="button" wire:click="clearCloudInitScript">
|
||||
Clear
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<x-dropdown inline panelClass="max-h-[55vh] overflow-y-auto scrollbar">
|
||||
<x-slot:title>
|
||||
<span class="text-sm font-medium">Advanced Vultr options</span>
|
||||
<span class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">SSH keys, network configuration, and cloud-init.</span>
|
||||
@if (count($this->advancedVultrOptionsSummary) > 0)
|
||||
<span class="mt-1 flex flex-wrap gap-1.5">
|
||||
@foreach ($this->advancedVultrOptionsSummary as $summaryItem)
|
||||
<span class="rounded bg-neutral-200 px-2 py-0.5 text-xs text-neutral-700 dark:bg-coolgray-100 dark:text-neutral-300">
|
||||
{{ $summaryItem }}
|
||||
</span>
|
||||
@endforeach
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<x-forms.textarea id="cloud_init_script" label=""
|
||||
helper="Add a cloud-init script to run when the server is created. Coolify sends it to Vultr as user data."
|
||||
rows="8" />
|
||||
</x-slot>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
|
||||
<div class="flex-1">
|
||||
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
|
||||
<div class="flex w-full flex-col gap-4 p-3">
|
||||
<div>
|
||||
<x-forms.datalist label="Additional SSH Keys (from Vultr)" id="selectedVultrSshKeyIds"
|
||||
helper="Select existing SSH keys from your Vultr account to add to this server. The Coolify SSH key will be automatically added."
|
||||
:multiple="true" :disabled="count($vultrSshKeys) === 0" :placeholder="count($vultrSshKeys) > 0 ? 'Search and select SSH keys...' : 'No SSH keys found in Vultr account'">
|
||||
@foreach ($vultrSshKeys as $sshKey)
|
||||
<option value="{{ $sshKey['id'] }}">
|
||||
{{ $sshKey['name'] }} - {{ substr($sshKey['id'], 0, 20) }}
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium">Network Configuration</label>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
|
||||
helper="Enable public IPv6 address for this server" fullWidth />
|
||||
<x-forms.checkbox id="disable_public_ipv4" label="Disable public IPv4"
|
||||
helper="Disable the default public IPv4 address" fullWidth />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
|
||||
@if ($saved_cloud_init_scripts->count() > 0)
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
|
||||
<option value="">Load saved script...</option>
|
||||
@foreach ($saved_cloud_init_scripts as $script)
|
||||
<option value="{{ $script->id }}">{{ $script->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
<x-forms.button type="button" wire:click="clearCloudInitScript">
|
||||
Clear
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<x-forms.textarea id="cloud_init_script" label=""
|
||||
helper="Add a cloud-init script to run when the server is created. Coolify sends it to Vultr as user data."
|
||||
rows="8" />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
|
||||
<div class="flex-1">
|
||||
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dropdown>
|
||||
|
||||
<div class="flex gap-2 justify-between">
|
||||
<x-forms.button type="button" wire:click="previousStep">
|
||||
Back
|
||||
</x-forms.button>
|
||||
<x-forms.button isHighlighted canGate="create" :canResource="App\Models\Server::class"
|
||||
type="submit" :disabled="!$private_key_id">
|
||||
Buy & Create Server{{ $this->selectedServerPrice ? ' (' . $this->selectedServerPrice . '/mo)' : '' }}
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<x-forms.button class="w-full" isHighlighted canGate="create" :canResource="App\Models\Server::class"
|
||||
type="submit" :disabled="!$private_key_id">
|
||||
Buy & Create Server{{ $this->selectedServerPrice ? ' (' . $this->selectedServerPrice . '/mo)' : '' }}
|
||||
</x-forms.button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,9 +9,54 @@
|
|||
<div class="flex items-end gap-2">
|
||||
<h2>Private Key</h2>
|
||||
@can('createAnyResource')
|
||||
<x-modal-input buttonTitle="+ Add" title="New Private Key">
|
||||
<livewire:security.private-key.create />
|
||||
</x-modal-input>
|
||||
<div x-data="{ dropdownOpen: false }" class="relative w-fit" @click.outside="dropdownOpen = false">
|
||||
<x-forms.button isHighlighted @click="dropdownOpen = !dropdownOpen" type="button">
|
||||
+ Add
|
||||
<svg class="w-4 h-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
|
||||
</svg>
|
||||
</x-forms.button>
|
||||
|
||||
<div x-show="dropdownOpen" @click.away="dropdownOpen=false" x-transition:enter="ease-out duration-200"
|
||||
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
|
||||
class="absolute top-0 z-50 mt-10 min-w-max" x-cloak>
|
||||
<div
|
||||
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
|
||||
<div class="flex flex-col gap-1">
|
||||
<a class="dropdown-item" wire:click="generatePrivateKey('ed25519')"
|
||||
@click="dropdownOpen = false">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Generate ED25519
|
||||
</a>
|
||||
<a class="dropdown-item" wire:click="generatePrivateKey('rsa')"
|
||||
@click="dropdownOpen = false">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Generate RSA
|
||||
</a>
|
||||
<x-modal-input title="Add Private Key Manually">
|
||||
<x-slot:content>
|
||||
<div class="dropdown-item" @click="dropdownOpen = false">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add manually
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<livewire:security.private-key.create />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
<x-forms.button canGate="update" :canResource="$server" isHighlighted wire:click.prevent='checkConnection'>
|
||||
Check connection
|
||||
|
|
@ -26,15 +71,21 @@ class="box-without-bg justify-between dark:bg-coolgray-100 bg-white items-center
|
|||
<div class="box-title">{{ $private_key->name }}</div>
|
||||
<div class="box-description">{{ $private_key->description }}</div>
|
||||
</div>
|
||||
@if (data_get($server, 'privateKey.uuid') !== $private_key->uuid)
|
||||
<x-forms.button canGate="update" :canResource="$server" class="w-full" wire:click='setPrivateKey({{ $private_key->id }})'>
|
||||
Use this key
|
||||
<div class="grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<x-forms.button class="w-full"
|
||||
@click.prevent="copyPublicKeyToClipboard({{ Js::from($private_key->public_key) }})">
|
||||
Copy public key
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button class="w-full" disabled>
|
||||
Currently used
|
||||
</x-forms.button>
|
||||
@endif
|
||||
@if (data_get($server, 'privateKey.uuid') !== $private_key->uuid)
|
||||
<x-forms.button canGate="update" :canResource="$server" class="w-full" wire:click='setPrivateKey({{ $private_key->id }})'>
|
||||
Use this key
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button class="w-full" disabled>
|
||||
Currently used
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div>No private keys found. </div>
|
||||
|
|
@ -42,4 +93,31 @@ class="box-without-bg justify-between dark:bg-coolgray-100 bg-white items-center
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@script
|
||||
<script>
|
||||
window.copyPublicKeyToClipboard = (publicKey) => {
|
||||
if (!publicKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
Livewire.dispatch('error', ['Failed to copy public key to clipboard.']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(publicKey).then(() => {
|
||||
Livewire.dispatch('success', ['Public key copied to clipboard.']);
|
||||
}).catch(() => {
|
||||
Livewire.dispatch('error', ['Failed to copy public key to clipboard.']);
|
||||
});
|
||||
};
|
||||
|
||||
$wire.on('copyPublicKeyToClipboard', (event) => {
|
||||
const publicKey = event?.detail?.publicKey ?? event?.publicKey;
|
||||
window.copyPublicKeyToClipboard(publicKey);
|
||||
});
|
||||
</script>
|
||||
@endscript
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -212,6 +212,11 @@ class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-warn
|
|||
<span>Validating...</span>
|
||||
</div>
|
||||
@endif
|
||||
@php
|
||||
$hasLinkableCloudProviders = (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
|
||||
|| (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
|
||||
|| (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty());
|
||||
@endphp
|
||||
@if ($server->id === 0)
|
||||
<x-modal-confirmation title="Confirm Server Settings Change?" buttonTitle="Save"
|
||||
submitAction="submit" :actions="[
|
||||
|
|
@ -221,6 +226,296 @@ class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-warn
|
|||
@else
|
||||
<x-forms.button type="submit" canGate="update" :canResource="$server"
|
||||
:disabled="$isValidating">Save</x-forms.button>
|
||||
@if ($hasLinkableCloudProviders)
|
||||
<div x-data="{ dropdownOpen: false }" class="relative w-fit" @click.outside="dropdownOpen = false">
|
||||
<x-forms.button @click="dropdownOpen = !dropdownOpen" type="button" canGate="update"
|
||||
:canResource="$server" :disabled="$isValidating">
|
||||
Link Cloud Provider
|
||||
<svg class="w-4 h-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
|
||||
</svg>
|
||||
</x-forms.button>
|
||||
<div x-show="dropdownOpen" @click.away="dropdownOpen=false"
|
||||
x-transition:enter="ease-out duration-200"
|
||||
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
|
||||
class="absolute top-0 z-50 mt-10 min-w-max" x-cloak>
|
||||
<div
|
||||
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
|
||||
<div class="flex flex-col gap-1">
|
||||
@if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
|
||||
<x-modal-input title="Link to Hetzner Cloud" :wireIgnore="false">
|
||||
<x-slot:content>
|
||||
<div class="dropdown-item" @click="dropdownOpen = false">
|
||||
<svg class="size-4" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Hetzner
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm dark:text-neutral-400">
|
||||
Link this server to a Hetzner Cloud instance to enable power controls and status monitoring.
|
||||
</p>
|
||||
<div class="w-full">
|
||||
<x-forms.select wire:model.live="selectedHetznerTokenId"
|
||||
label="Hetzner Token" canGate="update" :canResource="$server">
|
||||
<option value="">Select a token...</option>
|
||||
@foreach ($availableHetznerTokens as $token)
|
||||
<option value="{{ $token->id }}">{{ $token->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<x-forms.input wire:model="manualHetznerServerId"
|
||||
label="Server ID" placeholder="e.g., 12345678"
|
||||
helper="Enter the Hetzner Server ID from your Hetzner Cloud console"
|
||||
canGate="update" :canResource="$server" />
|
||||
</div>
|
||||
<x-forms.button wire:click="searchHetznerServerById"
|
||||
wire:loading.attr="disabled" canGate="update"
|
||||
:canResource="$server" :disabled="! $selectedHetznerTokenId">
|
||||
<span wire:loading.remove wire:target="searchHetznerServerById">Search</span>
|
||||
<span wire:loading wire:target="searchHetznerServerById">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm dark:text-neutral-500">
|
||||
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
|
||||
<span>OR</span>
|
||||
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
|
||||
</div>
|
||||
<x-forms.button wire:click="searchHetznerServer"
|
||||
wire:loading.attr="disabled" canGate="update"
|
||||
:canResource="$server" :disabled="! $selectedHetznerTokenId"
|
||||
class="w-full justify-center">
|
||||
<span wire:loading.remove wire:target="searchHetznerServer">Search by IP</span>
|
||||
<span wire:loading wire:target="searchHetznerServer">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@if ($hetznerSearchError)
|
||||
<div class="p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<p class="text-red-600 dark:text-red-400">{{ $hetznerSearchError }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@if ($hetznerNoMatchFound)
|
||||
<div class="p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<p class="text-yellow-600 dark:text-yellow-400">
|
||||
@if ($manualHetznerServerId)
|
||||
No Hetzner server found with ID: {{ $manualHetznerServerId }}
|
||||
@else
|
||||
No Hetzner server found matching IP: {{ $server->ip }}
|
||||
@endif
|
||||
</p>
|
||||
<p class="mt-1 text-sm dark:text-neutral-400">
|
||||
Try a different token, enter the Server ID manually, or verify the details are correct.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
@if ($matchedHetznerServer)
|
||||
<div class="p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
|
||||
<h4 class="mb-2 font-semibold text-green-700 dark:text-green-400">Match Found!</h4>
|
||||
<div class="grid gap-2 mb-4 text-sm lg:grid-cols-2">
|
||||
<div><span class="font-medium">Name:</span> {{ $matchedHetznerServer['name'] }}</div>
|
||||
<div><span class="font-medium">ID:</span> {{ $matchedHetznerServer['id'] }}</div>
|
||||
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedHetznerServer['status']) }}</div>
|
||||
<div><span class="font-medium">Type:</span> {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}</div>
|
||||
</div>
|
||||
<x-forms.button wire:click="linkToHetzner" isHighlighted canGate="update" :canResource="$server">
|
||||
Link This Server
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-modal-input>
|
||||
@endif
|
||||
@if (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty())
|
||||
<x-modal-input title="Link to DigitalOcean" :wireIgnore="false">
|
||||
<x-slot:content>
|
||||
<div class="dropdown-item" @click="dropdownOpen = false">
|
||||
<svg class="size-4" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
DigitalOcean
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm dark:text-neutral-400">
|
||||
Link this server to a DigitalOcean droplet to enable power controls and status monitoring.
|
||||
</p>
|
||||
<div class="w-full">
|
||||
<x-forms.select wire:model.live="selectedDigitalOceanTokenId"
|
||||
label="DigitalOcean Token" canGate="update" :canResource="$server">
|
||||
<option value="">Select a token...</option>
|
||||
@foreach ($availableDigitalOceanTokens as $token)
|
||||
<option value="{{ $token->id }}">{{ $token->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<x-forms.input wire:model="manualDigitalOceanDropletId"
|
||||
label="Droplet ID" placeholder="e.g., 123456789"
|
||||
helper="Enter the Droplet ID from your DigitalOcean dashboard"
|
||||
canGate="update" :canResource="$server" />
|
||||
</div>
|
||||
<x-forms.button wire:click="searchDigitalOceanDropletById"
|
||||
wire:loading.attr="disabled" canGate="update"
|
||||
:canResource="$server" :disabled="! $selectedDigitalOceanTokenId">
|
||||
<span wire:loading.remove wire:target="searchDigitalOceanDropletById">Search</span>
|
||||
<span wire:loading wire:target="searchDigitalOceanDropletById">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm dark:text-neutral-500">
|
||||
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
|
||||
<span>OR</span>
|
||||
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
|
||||
</div>
|
||||
<x-forms.button wire:click="searchDigitalOceanDroplet"
|
||||
wire:loading.attr="disabled" canGate="update"
|
||||
:canResource="$server" :disabled="! $selectedDigitalOceanTokenId"
|
||||
class="w-full justify-center">
|
||||
<span wire:loading.remove wire:target="searchDigitalOceanDroplet">Search by IP</span>
|
||||
<span wire:loading wire:target="searchDigitalOceanDroplet">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@if ($digitalOceanSearchError)
|
||||
<div class="p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<p class="text-red-600 dark:text-red-400">{{ $digitalOceanSearchError }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@if ($digitalOceanNoMatchFound)
|
||||
<div class="p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<p class="text-yellow-600 dark:text-yellow-400">
|
||||
@if ($manualDigitalOceanDropletId)
|
||||
No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }}
|
||||
@else
|
||||
No DigitalOcean droplet found matching IP: {{ $server->ip }}
|
||||
@endif
|
||||
</p>
|
||||
<p class="mt-1 text-sm dark:text-neutral-400">
|
||||
Try a different token, enter the Droplet ID manually, or verify the details are correct.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
@if ($matchedDigitalOceanDroplet)
|
||||
<div class="p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
|
||||
<h4 class="mb-2 font-semibold text-green-700 dark:text-green-400">Match Found!</h4>
|
||||
<div class="grid gap-2 mb-4 text-sm lg:grid-cols-2">
|
||||
<div><span class="font-medium">Name:</span> {{ $matchedDigitalOceanDroplet['name'] ?? 'Unknown' }}</div>
|
||||
<div><span class="font-medium">ID:</span> {{ $matchedDigitalOceanDroplet['id'] }}</div>
|
||||
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedDigitalOceanDroplet['status'] ?? 'unknown') }}</div>
|
||||
<div><span class="font-medium">Size:</span> {{ data_get($matchedDigitalOceanDroplet, 'size.slug', 'Unknown') }}</div>
|
||||
</div>
|
||||
<x-forms.button wire:click="linkToDigitalOcean" isHighlighted canGate="update" :canResource="$server">
|
||||
Link This Server
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-modal-input>
|
||||
@endif
|
||||
@if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
|
||||
<x-modal-input title="Link to Vultr" :wireIgnore="false">
|
||||
<x-slot:content>
|
||||
<div class="dropdown-item" @click="dropdownOpen = false">
|
||||
<svg class="size-4" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Vultr
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm dark:text-neutral-400">
|
||||
Link this server to a Vultr instance to enable power controls and status monitoring.
|
||||
</p>
|
||||
<div class="w-full">
|
||||
<x-forms.select wire:model.live="selectedVultrTokenId"
|
||||
label="Vultr Token" canGate="update" :canResource="$server">
|
||||
<option value="">Select a token...</option>
|
||||
@foreach ($availableVultrTokens as $token)
|
||||
<option value="{{ $token->id }}">{{ $token->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<x-forms.input wire:model="manualVultrInstanceId"
|
||||
label="Instance ID" placeholder="e.g., 6d4b..."
|
||||
helper="Enter the Vultr Instance ID from your Vultr dashboard"
|
||||
canGate="update" :canResource="$server" />
|
||||
</div>
|
||||
<x-forms.button wire:click="searchVultrInstanceById"
|
||||
wire:loading.attr="disabled" canGate="update"
|
||||
:canResource="$server" :disabled="! $selectedVultrTokenId">
|
||||
<span wire:loading.remove wire:target="searchVultrInstanceById">Search</span>
|
||||
<span wire:loading wire:target="searchVultrInstanceById">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm dark:text-neutral-500">
|
||||
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
|
||||
<span>OR</span>
|
||||
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
|
||||
</div>
|
||||
<x-forms.button wire:click="searchVultrInstance"
|
||||
wire:loading.attr="disabled" canGate="update"
|
||||
:canResource="$server" :disabled="! $selectedVultrTokenId"
|
||||
class="w-full justify-center">
|
||||
<span wire:loading.remove wire:target="searchVultrInstance">Search by IP</span>
|
||||
<span wire:loading wire:target="searchVultrInstance">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@if ($vultrSearchError)
|
||||
<div class="p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<p class="text-red-600 dark:text-red-400">{{ $vultrSearchError }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@if ($vultrNoMatchFound)
|
||||
<div class="p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<p class="text-yellow-600 dark:text-yellow-400">
|
||||
@if ($manualVultrInstanceId)
|
||||
No Vultr instance found with ID: {{ $manualVultrInstanceId }}
|
||||
@else
|
||||
No Vultr instance found matching IP: {{ $server->ip }}
|
||||
@endif
|
||||
</p>
|
||||
<p class="mt-1 text-sm dark:text-neutral-400">
|
||||
Try a different token, enter the Instance ID manually, or verify the details are correct.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
@if ($matchedVultrInstance)
|
||||
<div class="p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
|
||||
<h4 class="mb-2 font-semibold text-green-700 dark:text-green-400">Match Found!</h4>
|
||||
<div class="grid gap-2 mb-4 text-sm lg:grid-cols-2">
|
||||
<div><span class="font-medium">Name:</span> {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}</div>
|
||||
<div><span class="font-medium">ID:</span> {{ $matchedVultrInstance['id'] }}</div>
|
||||
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}</div>
|
||||
<div><span class="font-medium">Plan:</span> {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}</div>
|
||||
</div>
|
||||
<x-forms.button wire:click="linkToVultr" isHighlighted canGate="update" :canResource="$server">
|
||||
Link This Server
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-modal-input>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if ($server->isFunctional())
|
||||
<x-slide-over closeWithX fullScreen>
|
||||
<x-slot:title>Validate & configure</x-slot:title>
|
||||
|
|
@ -482,228 +777,6 @@ class="dark:hover:fill-white fill-black dark:fill-warning">
|
|||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
|
||||
<div class="pt-6">
|
||||
<h3>Link to Hetzner Cloud</h3>
|
||||
<p class="pb-4 text-sm dark:text-neutral-400">
|
||||
Link this server to a Hetzner Cloud instance to enable power controls and status monitoring.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-4 items-end">
|
||||
<div class="w-72">
|
||||
<x-forms.select wire:model="selectedHetznerTokenId" label="Hetzner Token"
|
||||
canGate="update" :canResource="$server">
|
||||
<option value="">Select a token...</option>
|
||||
@foreach ($availableHetznerTokens as $token)
|
||||
<option value="{{ $token->id }}">{{ $token->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<x-forms.input wire:model="manualHetznerServerId"
|
||||
label="Server ID"
|
||||
placeholder="e.g., 12345678"
|
||||
helper="Enter the Hetzner Server ID from your Hetzner Cloud console"
|
||||
canGate="update" :canResource="$server" />
|
||||
</div>
|
||||
<x-forms.button wire:click="searchHetznerServerById"
|
||||
wire:loading.attr="disabled"
|
||||
canGate="update" :canResource="$server">
|
||||
<span wire:loading.remove wire:target="searchHetznerServerById">Search by ID</span>
|
||||
<span wire:loading wire:target="searchHetznerServerById">Searching...</span>
|
||||
</x-forms.button>
|
||||
<div class="self-end pb-2 text-sm dark:text-neutral-500">OR</div>
|
||||
<x-forms.button wire:click="searchHetznerServer"
|
||||
wire:loading.attr="disabled"
|
||||
canGate="update" :canResource="$server">
|
||||
<span wire:loading.remove wire:target="searchHetznerServer">Search by IP</span>
|
||||
<span wire:loading wire:target="searchHetznerServer">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
@if ($hetznerSearchError)
|
||||
<div class="mt-4 p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<p class="text-red-600 dark:text-red-400">{{ $hetznerSearchError }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($hetznerNoMatchFound)
|
||||
<div class="mt-4 p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<p class="text-yellow-600 dark:text-yellow-400">
|
||||
@if ($manualHetznerServerId)
|
||||
No Hetzner server found with ID: {{ $manualHetznerServerId }}
|
||||
@else
|
||||
No Hetzner server found matching IP: {{ $server->ip }}
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-sm dark:text-neutral-400 mt-1">
|
||||
Try a different token, enter the Server ID manually, or verify the details are correct.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($matchedHetznerServer)
|
||||
<div class="mt-4 p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
|
||||
<h4 class="font-semibold text-green-700 dark:text-green-400 mb-2">Match Found!</h4>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
|
||||
<div><span class="font-medium">Name:</span> {{ $matchedHetznerServer['name'] }}</div>
|
||||
<div><span class="font-medium">ID:</span> {{ $matchedHetznerServer['id'] }}</div>
|
||||
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedHetznerServer['status']) }}</div>
|
||||
<div><span class="font-medium">Type:</span> {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}</div>
|
||||
</div>
|
||||
<x-forms.button wire:click="linkToHetzner" isHighlighted canGate="update" :canResource="$server">
|
||||
Link This Server
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
|
||||
<div class="pt-6">
|
||||
<h3>Link to Vultr</h3>
|
||||
<p class="pb-4 text-sm dark:text-neutral-400">
|
||||
Link this server to a Vultr instance to enable power controls and status monitoring.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-4 items-end">
|
||||
<div class="w-72">
|
||||
<x-forms.select wire:model="selectedVultrTokenId" label="Vultr Token"
|
||||
canGate="update" :canResource="$server">
|
||||
<option value="">Select a token...</option>
|
||||
@foreach ($availableVultrTokens as $token)
|
||||
<option value="{{ $token->id }}">{{ $token->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="w-72">
|
||||
<x-forms.input wire:model="manualVultrInstanceId"
|
||||
label="Instance ID"
|
||||
placeholder="e.g., 6d4b..."
|
||||
helper="Enter the Vultr Instance ID from your Vultr dashboard"
|
||||
canGate="update" :canResource="$server" />
|
||||
</div>
|
||||
<x-forms.button wire:click="searchVultrInstanceById"
|
||||
wire:loading.attr="disabled"
|
||||
canGate="update" :canResource="$server">
|
||||
<span wire:loading.remove wire:target="searchVultrInstanceById">Search by ID</span>
|
||||
<span wire:loading wire:target="searchVultrInstanceById">Searching...</span>
|
||||
</x-forms.button>
|
||||
<div class="self-end pb-2 text-sm dark:text-neutral-500">OR</div>
|
||||
<x-forms.button wire:click="searchVultrInstance"
|
||||
wire:loading.attr="disabled"
|
||||
canGate="update" :canResource="$server">
|
||||
<span wire:loading.remove wire:target="searchVultrInstance">Search by IP</span>
|
||||
<span wire:loading wire:target="searchVultrInstance">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
@if ($vultrSearchError)
|
||||
<div class="mt-4 p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<p class="text-red-600 dark:text-red-400">{{ $vultrSearchError }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($vultrNoMatchFound)
|
||||
<div class="mt-4 p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<p class="text-yellow-600 dark:text-yellow-400">
|
||||
@if ($manualVultrInstanceId)
|
||||
No Vultr instance found with ID: {{ $manualVultrInstanceId }}
|
||||
@else
|
||||
No Vultr instance found matching IP: {{ $server->ip }}
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-sm dark:text-neutral-400 mt-1">
|
||||
Try a different token, enter the Instance ID manually, or verify the details are correct.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($matchedVultrInstance)
|
||||
<div class="mt-4 p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
|
||||
<h4 class="font-semibold text-green-700 dark:text-green-400 mb-2">Match Found!</h4>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
|
||||
<div><span class="font-medium">Name:</span> {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}</div>
|
||||
<div><span class="font-medium">ID:</span> {{ $matchedVultrInstance['id'] }}</div>
|
||||
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}</div>
|
||||
<div><span class="font-medium">Plan:</span> {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}</div>
|
||||
</div>
|
||||
<x-forms.button wire:click="linkToVultr" isHighlighted canGate="update" :canResource="$server">
|
||||
Link This Server
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty())
|
||||
<div class="pt-6">
|
||||
<h3>Link to DigitalOcean</h3>
|
||||
<p class="pb-4 text-sm dark:text-neutral-400">
|
||||
Link this server to a DigitalOcean droplet to enable power controls and status monitoring.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-4 items-end">
|
||||
<div class="w-72">
|
||||
<x-forms.select wire:model="selectedDigitalOceanTokenId" label="DigitalOcean Token"
|
||||
canGate="update" :canResource="$server">
|
||||
<option value="">Select a token...</option>
|
||||
@foreach ($availableDigitalOceanTokens as $token)
|
||||
<option value="{{ $token->id }}">{{ $token->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="w-72">
|
||||
<x-forms.input wire:model="manualDigitalOceanDropletId" label="Droplet ID"
|
||||
placeholder="e.g., 123456789"
|
||||
helper="Enter the Droplet ID from your DigitalOcean dashboard"
|
||||
canGate="update" :canResource="$server" />
|
||||
</div>
|
||||
<x-forms.button wire:click="searchDigitalOceanDropletById" wire:loading.attr="disabled"
|
||||
canGate="update" :canResource="$server">
|
||||
<span wire:loading.remove wire:target="searchDigitalOceanDropletById">Search by ID</span>
|
||||
<span wire:loading wire:target="searchDigitalOceanDropletById">Searching...</span>
|
||||
</x-forms.button>
|
||||
<div class="self-end pb-2 text-sm dark:text-neutral-500">OR</div>
|
||||
<x-forms.button wire:click="searchDigitalOceanDroplet" wire:loading.attr="disabled"
|
||||
canGate="update" :canResource="$server">
|
||||
<span wire:loading.remove wire:target="searchDigitalOceanDroplet">Search by IP</span>
|
||||
<span wire:loading wire:target="searchDigitalOceanDroplet">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
@if ($digitalOceanSearchError)
|
||||
<div class="mt-4 p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<p class="text-red-600 dark:text-red-400">{{ $digitalOceanSearchError }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($digitalOceanNoMatchFound)
|
||||
<div class="mt-4 p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<p class="text-yellow-600 dark:text-yellow-400">
|
||||
@if ($manualDigitalOceanDropletId)
|
||||
No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }}
|
||||
@else
|
||||
No DigitalOcean droplet found matching IP: {{ $server->ip }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($matchedDigitalOceanDroplet)
|
||||
<div class="mt-4 p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
|
||||
<h4 class="font-semibold text-green-700 dark:text-green-400 mb-2">Match Found!</h4>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
|
||||
<div><span class="font-medium">Name:</span> {{ $matchedDigitalOceanDroplet['name'] ?? 'Unknown' }}</div>
|
||||
<div><span class="font-medium">ID:</span> {{ $matchedDigitalOceanDroplet['id'] }}</div>
|
||||
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedDigitalOceanDroplet['status'] ?? 'unknown') }}</div>
|
||||
<div><span class="font-medium">Size:</span> {{ data_get($matchedDigitalOceanDroplet, 'size.slug', 'Unknown') }}</div>
|
||||
</div>
|
||||
<x-forms.button wire:click="linkToDigitalOcean" isHighlighted canGate="update" :canResource="$server">
|
||||
Link This Server
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -46,6 +48,7 @@
|
|||
use App\Livewire\Server\Charts as ServerCharts;
|
||||
use App\Livewire\Server\CloudflareTunnel;
|
||||
use App\Livewire\Server\CloudProviderToken\Show as CloudProviderTokenShow;
|
||||
use App\Livewire\Server\CreatePage as ServerCreatePage;
|
||||
use App\Livewire\Server\Delete as DeleteServer;
|
||||
use App\Livewire\Server\Destinations as ServerDestinations;
|
||||
use App\Livewire\Server\DockerCleanup;
|
||||
|
|
@ -88,6 +91,7 @@
|
|||
use App\Livewire\Team\Member\Index as TeamMemberIndex;
|
||||
use App\Livewire\Terminal\Index as TerminalIndex;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -277,7 +281,9 @@
|
|||
});
|
||||
|
||||
Route::get('/servers', ServerIndex::class)->name('server.index');
|
||||
// Route::get('/server/new', ServerCreate::class)->name('server.create');
|
||||
Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class);
|
||||
Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean']);
|
||||
Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'manual']);
|
||||
|
||||
Route::prefix('server/{server_uuid}')->group(function () {
|
||||
Route::get('/', ServerShow::class)->name('server.show');
|
||||
|
|
@ -313,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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ function createServerWithKeyForTeam(Team $team): void
|
|||
createServerWithKeyForTeam($team);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertSee('New Server');
|
||||
->assertSee(route('server.create'));
|
||||
});
|
||||
|
||||
test('member does not see add server button on dashboard', function () {
|
||||
|
|
@ -93,5 +93,5 @@ function createServerWithKeyForTeam(Team $team): void
|
|||
createServerWithKeyForTeam($team);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertDontSee('New Server');
|
||||
->assertDontSee(route('server.create'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\PreventRequestsDuringMaintenance;
|
||||
use App\Livewire\Server\Create as ServerCreate;
|
||||
use App\Livewire\Server\Index as ServerIndex;
|
||||
use App\Livewire\Server\Navbar as ServerNavbar;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
|
|
@ -132,6 +135,144 @@
|
|||
expect(auth()->user()->can('create', Server::class))->toBeFalse();
|
||||
});
|
||||
|
||||
test('admin can access new server page', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->get(route('server.create'))
|
||||
->assertSuccessful()
|
||||
->assertSee('New Server')
|
||||
->assertSeeLivewire(ServerCreate::class);
|
||||
});
|
||||
|
||||
test('new server chooser lists providers before rendering a creation form', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ServerCreate::class)
|
||||
->assertSee('Hetzner')
|
||||
->assertSee('Vultr')
|
||||
->assertSee('DigitalOcean')
|
||||
->assertSee('Manual')
|
||||
->assertDontSee('>Select<', false)
|
||||
->assertDontSee('Continue')
|
||||
->assertSee(route('server.create.type', ['type' => 'hetzner']))
|
||||
->assertSee(route('server.create.type', ['type' => 'vultr']))
|
||||
->assertSee(route('server.create.type', ['type' => 'digital-ocean']))
|
||||
->assertSee(route('server.create.type', ['type' => 'manual']))
|
||||
->assertDontSee('Add Server by IP Address');
|
||||
});
|
||||
|
||||
test('new server chooser uses compact mobile provider cards', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ServerCreate::class)
|
||||
->assertSee('mx-auto flex w-full max-w-7xl flex-col', false)
|
||||
->assertSee('sm:grid', false)
|
||||
->assertSee('sm:grid-cols-2', false)
|
||||
->assertSee('xl:grid-cols-4', false)
|
||||
->assertSee('gap-3 sm:gap-6', false)
|
||||
->assertSee('dark:hover:border-warning', false)
|
||||
->assertSee('focus-visible:border-coollabs', false)
|
||||
->assertSee('aria-label="Choose Hetzner"', false)
|
||||
->assertSee('p-3 sm:p-6', false)
|
||||
->assertDontSee('sm:min-h-80', false)
|
||||
->assertSee('size-9 sm:size-14', false)
|
||||
->assertDontSee('size-9 sm:size-14 w-14 h-14', false)
|
||||
->assertSee('hidden sm:block', false)
|
||||
->assertDontSee('>Select<', false)
|
||||
->assertDontSee('<button', false);
|
||||
});
|
||||
|
||||
test('new server provider pages render the selected creation flow', function (string $type, string $heading) {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$response = $this->get(route('server.create.type', ['type' => $type]))
|
||||
->assertSuccessful()
|
||||
->assertSee($heading)
|
||||
->assertDontSee('<h1>New Server</h1>', false)
|
||||
->assertDontSee('Choose how to add your server');
|
||||
|
||||
$response->assertSeeInOrder([$heading, 'Back']);
|
||||
})->with([
|
||||
['hetzner', 'Hetzner'],
|
||||
['vultr', 'Vultr'],
|
||||
['digital-ocean', 'DigitalOcean'],
|
||||
['manual', 'Manual'],
|
||||
]);
|
||||
|
||||
test('new server provider pages do not show the new token action in the header', function (string $type, string $heading) {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$response = $this->get(route('server.create.type', ['type' => $type]))
|
||||
->assertSuccessful()
|
||||
->assertDontSee('+ New Token')
|
||||
->assertDontSee('+ Add New Token');
|
||||
|
||||
$response->assertSeeInOrder([$heading, 'Back']);
|
||||
})->with([
|
||||
['hetzner', 'Hetzner'],
|
||||
['vultr', 'Vultr'],
|
||||
['digital-ocean', 'DigitalOcean'],
|
||||
]);
|
||||
|
||||
test('new server provider pages show the new token action in the header when tokens exist', function (string $type, string $provider, string $heading, string $modalTitle) {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('server.create.type', ['type' => $type]))
|
||||
->assertSuccessful()
|
||||
->assertSee($modalTitle);
|
||||
|
||||
$response->assertSeeInOrder([$heading, 'Back', '+ New Token']);
|
||||
})->with([
|
||||
['hetzner', 'hetzner', 'Hetzner', 'Add Hetzner Token'],
|
||||
['vultr', 'vultr', 'Vultr', 'Add Vultr Token'],
|
||||
['digital-ocean', 'digitalocean', 'DigitalOcean', 'Add DigitalOcean Token'],
|
||||
]);
|
||||
|
||||
test('new server manual page does not show the new token action', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->get(route('server.create.type', ['type' => 'manual']))
|
||||
->assertSuccessful()
|
||||
->assertSee('Manual')
|
||||
->assertDontSee('+ New Token');
|
||||
});
|
||||
|
||||
test('member cannot access new server page', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->get(route('server.create'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('server index links admins to new server page', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ServerIndex::class)
|
||||
->assertSee(route('server.create'));
|
||||
});
|
||||
|
||||
test('server index does not link members to new server page', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ServerIndex::class)
|
||||
->assertDontSee(route('server.create'));
|
||||
});
|
||||
|
||||
// --- Cross-team isolation ---
|
||||
|
||||
test('user from different team cannot view server', function () {
|
||||
|
|
|
|||
192
tests/Feature/CloudProviderLinkDropdownTest.php
Normal file
192
tests/Feature/CloudProviderLinkDropdownTest.php
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\Show;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.maintenance.driver' => 'file',
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_api_enabled' => true,
|
||||
]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'ip' => '1.2.3.4',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows link cloud provider dropdown with available unlinked providers', function () {
|
||||
CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'test-hetzner-token',
|
||||
'name' => 'Test Hetzner Token',
|
||||
]);
|
||||
|
||||
CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vultr',
|
||||
'token' => 'test-vultr-token',
|
||||
'name' => 'Test Vultr Token',
|
||||
]);
|
||||
|
||||
CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'digitalocean',
|
||||
'token' => 'test-digitalocean-token',
|
||||
'name' => 'Test DigitalOcean Token',
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSee('Link Cloud Provider')
|
||||
->assertSee('Hetzner')
|
||||
->assertSee('DigitalOcean')
|
||||
->assertSee('Vultr')
|
||||
->assertSee('Hetzner Token')
|
||||
->assertSee('DigitalOcean Token')
|
||||
->assertSee('Vultr Token')
|
||||
->assertSee('Server ID')
|
||||
->assertSee('Droplet ID')
|
||||
->assertSee('Instance ID')
|
||||
->assertSee('Search by IP')
|
||||
->assertSee('Search');
|
||||
});
|
||||
|
||||
it('hides link cloud provider dropdown when no providers can be linked', function () {
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertDontSee('Link Cloud Provider');
|
||||
});
|
||||
|
||||
it('does not list providers already linked to the server', function () {
|
||||
CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'test-hetzner-token',
|
||||
'name' => 'Test Hetzner Token',
|
||||
]);
|
||||
|
||||
CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vultr',
|
||||
'token' => 'test-vultr-token',
|
||||
'name' => 'Test Vultr Token',
|
||||
]);
|
||||
|
||||
$this->server->update(['hetzner_server_id' => 123]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSee('Link Cloud Provider')
|
||||
->assertSee('Vultr Token')
|
||||
->assertDontSee('Hetzner Token');
|
||||
});
|
||||
|
||||
it('shows Hetzner search by IP errors in the modal', function () {
|
||||
$token = CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'invalid-hetzner-token',
|
||||
'name' => 'Invalid Hetzner Token',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/servers*' => Http::response([
|
||||
'error' => ['message' => 'invalid token'],
|
||||
], 401),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('selectedHetznerTokenId', $token->id)
|
||||
->call('searchHetznerServer')
|
||||
->assertSet('hetznerSearchError', fn (string $error) => str_contains($error, 'Failed to search Hetzner servers:') && str_contains($error, 'invalid token'))
|
||||
->assertSee('Failed to search Hetzner servers:')
|
||||
->assertSee('invalid token');
|
||||
});
|
||||
|
||||
it('shows Hetzner search by ID errors in the modal', function () {
|
||||
$token = CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'invalid-hetzner-token',
|
||||
'name' => 'Invalid Hetzner Token',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/servers/12345678' => Http::response([
|
||||
'error' => ['message' => 'invalid token'],
|
||||
], 401),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('selectedHetznerTokenId', $token->id)
|
||||
->set('manualHetznerServerId', '12345678')
|
||||
->call('searchHetznerServerById')
|
||||
->assertSet('hetznerSearchError', fn (string $error) => str_contains($error, 'Failed to fetch Hetzner server:') && str_contains($error, 'invalid token'))
|
||||
->assertSee('Failed to fetch Hetzner server:')
|
||||
->assertSee('invalid token');
|
||||
});
|
||||
|
||||
it('shows DigitalOcean search errors in the modal', function () {
|
||||
$token = CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'digitalocean',
|
||||
'token' => 'invalid-digitalocean-token',
|
||||
'name' => 'Invalid DigitalOcean Token',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets*' => Http::response([
|
||||
'message' => 'invalid token',
|
||||
], 401),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('selectedDigitalOceanTokenId', $token->id)
|
||||
->call('searchDigitalOceanDroplet')
|
||||
->assertSet('digitalOceanSearchError', fn (string $error) => str_contains($error, 'Failed to search DigitalOcean droplets:') && str_contains($error, 'invalid token'))
|
||||
->assertSee('Failed to search DigitalOcean droplets:')
|
||||
->assertSee('invalid token');
|
||||
});
|
||||
|
||||
it('shows Vultr search errors in the modal', function () {
|
||||
$token = CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vultr',
|
||||
'token' => 'invalid-vultr-token',
|
||||
'name' => 'Invalid Vultr Token',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances*' => Http::response([
|
||||
'error' => 'invalid token',
|
||||
], 401),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('selectedVultrTokenId', $token->id)
|
||||
->call('searchVultrInstance')
|
||||
->assertSet('vultrSearchError', fn (string $error) => str_contains($error, 'Failed to search Vultr instances:') && str_contains($error, 'invalid token'))
|
||||
->assertSee('Failed to search Vultr instances:')
|
||||
->assertSee('invalid token');
|
||||
});
|
||||
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.');
|
||||
});
|
||||
36
tests/Feature/DigitalOceanServerCreationTest.php
Normal file
36
tests/Feature/DigitalOceanServerCreationTest.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByDigitalOcean;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
]));
|
||||
|
||||
$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]);
|
||||
});
|
||||
|
||||
it('renders only the full width buy button at the bottom of the DigitalOcean form', function () {
|
||||
Livewire::test(ByDigitalOcean::class)
|
||||
->set('current_step', 2)
|
||||
->assertDontSee('wire:click="previousStep"', false)
|
||||
->assertSeeHtml('class="button w-full"')
|
||||
->assertSee('Buy & Create Server', false);
|
||||
});
|
||||
|
|
@ -82,6 +82,13 @@ function githubPrivateRepositoryTestPrivateKeyForTeam(Team $team): PrivateKey
|
|||
}
|
||||
|
||||
describe('GitHub Private Repository Component', function () {
|
||||
test('github app selection uses coolbox loading border', function () {
|
||||
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->assertSee('wire:loading.class="coolbox-loading"', false)
|
||||
->assertSee('wire:target="loadRepositories('.$this->githubApp->id.')"', false)
|
||||
->assertDontSee('loading-spinner', false);
|
||||
});
|
||||
|
||||
test('loadRepositories fetches and displays repositories', function () {
|
||||
$repos = [
|
||||
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
|
||||
|
|
|
|||
93
tests/Feature/NewServerPrivateKeySelectionTest.php
Normal file
93
tests/Feature/NewServerPrivateKeySelectionTest.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByIp;
|
||||
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\Storage;
|
||||
use Livewire\Livewire;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
Storage::fake('ssh-keys');
|
||||
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$this->existingPrivateKey = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'name' => 'Existing SSH Key',
|
||||
'description' => 'Existing SSH Key',
|
||||
'private_key' => 'test-private-key',
|
||||
'team_id' => $this->team->id,
|
||||
]));
|
||||
});
|
||||
|
||||
it('generates and preselects a new private key without clearing server form data', function () {
|
||||
$component = Livewire::test(ByIp::class, [
|
||||
'private_keys' => collect([$this->existingPrivateKey]),
|
||||
'limit_reached' => false,
|
||||
])
|
||||
->set('name', 'Production Server')
|
||||
->set('description', 'Deploy target')
|
||||
->set('ip', '192.0.2.50')
|
||||
->set('user', 'deploy.user')
|
||||
->set('port', 2222)
|
||||
->set('is_build_server', true)
|
||||
->call('generatePrivateKey', 'ed25519')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('name', 'Production Server')
|
||||
->assertSet('description', 'Deploy target')
|
||||
->assertSet('ip', '192.0.2.50')
|
||||
->assertSet('user', 'deploy.user')
|
||||
->assertSet('port', 2222)
|
||||
->assertSet('is_build_server', true);
|
||||
|
||||
$newPrivateKeyId = $component->get('private_key_id');
|
||||
|
||||
expect($newPrivateKeyId)->not->toBe($this->existingPrivateKey->id)
|
||||
->and(PrivateKey::find($newPrivateKeyId))->not->toBeNull()
|
||||
->and($component->get('private_keys')->pluck('id'))->toContain($newPrivateKeyId);
|
||||
});
|
||||
|
||||
it('preselects a manually added private key without clearing server form data', function () {
|
||||
$manualPrivateKey = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'name' => 'Manual SSH Key',
|
||||
'description' => 'Manual SSH Key',
|
||||
'private_key' => 'manual-test-private-key',
|
||||
'team_id' => $this->team->id,
|
||||
]));
|
||||
|
||||
Livewire::test(ByIp::class, [
|
||||
'private_keys' => collect([$this->existingPrivateKey]),
|
||||
'limit_reached' => false,
|
||||
])
|
||||
->set('name', 'Production Server')
|
||||
->set('description', 'Deploy target')
|
||||
->set('ip', '192.0.2.51')
|
||||
->set('user', 'deploy.user')
|
||||
->set('port', 2222)
|
||||
->set('is_build_server', true)
|
||||
->call('handlePrivateKeyCreated', $manualPrivateKey->id)
|
||||
->assertSet('private_key_id', $manualPrivateKey->id)
|
||||
->assertSet('name', 'Production Server')
|
||||
->assertSet('description', 'Deploy target')
|
||||
->assertSet('ip', '192.0.2.51')
|
||||
->assertSet('user', 'deploy.user')
|
||||
->assertSet('port', 2222)
|
||||
->assertSet('is_build_server', true)
|
||||
->assertSee('Manual SSH Key');
|
||||
});
|
||||
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);
|
||||
});
|
||||
134
tests/Feature/Security/PrivateKeyDropdownTest.php
Normal file
134
tests/Feature/Security/PrivateKeyDropdownTest.php
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Security\PrivateKey\Create;
|
||||
use App\Livewire\Security\PrivateKey\Index;
|
||||
use App\Livewire\Security\PrivateKey\Show;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
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);
|
||||
});
|
||||
|
||||
test('used private key details disable delete with an explanation', function () {
|
||||
$privateKey = PrivateKey::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
|
||||
$this->get(route('security.private-key.show', [
|
||||
'private_key_uuid' => $privateKey->uuid,
|
||||
]))
|
||||
->assertSuccessful()
|
||||
->assertSee('This private key is currently used by a server, application, or Git app and cannot be deleted.', false)
|
||||
->assertSee('disabled', false);
|
||||
});
|
||||
|
||||
test('used private key delete action keeps the key and shows an error', function () {
|
||||
$privateKey = PrivateKey::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['private_key_uuid' => $privateKey->uuid])
|
||||
->call('delete')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($privateKey->fresh())->not->toBeNull();
|
||||
});
|
||||
|
|
@ -290,10 +290,9 @@
|
|||
], 200),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ByHetzner::class)
|
||||
->set('selected_token_id', $this->hetznerToken->id)
|
||||
->call('nextStep')
|
||||
->assertSet('current_step', 2);
|
||||
$component = Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid])
|
||||
->assertSet('current_step', 2)
|
||||
->call('loadHetznerData');
|
||||
|
||||
expect($component->get('hetznerFirewalls'))->toHaveCount(1)
|
||||
->and($component->get('hetznerNetworks'))->toHaveCount(1);
|
||||
|
|
|
|||
197
tests/Feature/Server/NewServerProviderTokenUrlTest.php
Normal file
197
tests/Feature/Server/NewServerProviderTokenUrlTest.php
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\CreatePage;
|
||||
use App\Livewire\Server\New\ByDigitalOcean;
|
||||
use App\Livewire\Server\New\ByHetzner;
|
||||
use App\Livewire\Server\New\ByVultr;
|
||||
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 Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
test('provider token selection redirects to a token specific server creation url', function (string $component, string $provider, string $type) {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
Livewire::test($component)
|
||||
->call('selectToken', $token->id)
|
||||
->assertRedirect(route('server.create.token', [
|
||||
'type' => $type,
|
||||
'token_uuid' => $token->uuid,
|
||||
]));
|
||||
})->with([
|
||||
'hetzner' => [ByHetzner::class, 'hetzner', 'hetzner'],
|
||||
'vultr' => [ByVultr::class, 'vultr', 'vultr'],
|
||||
'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean'],
|
||||
]);
|
||||
|
||||
test('provider token selection is rendered as clickable token boxes', function (string $component, string $provider, string $type, string $tokenName) {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
'name' => $tokenName,
|
||||
]);
|
||||
|
||||
Livewire::test($component)
|
||||
->assertSee($tokenName)
|
||||
->assertSee(route('server.create.token', ['type' => $type, 'token_uuid' => $token->uuid]))
|
||||
->assertDontSee('loadingProviderTokenId', false)
|
||||
->assertDontSee('coolbox-loading', false)
|
||||
->assertSee('coolbox', false)
|
||||
->assertDontSee('wire:click="selectToken(', false)
|
||||
->assertDontSee('loading-spinner', false)
|
||||
->assertDontSee('Loading Hetzner details...')
|
||||
->assertDontSee('Loading Vultr details...')
|
||||
->assertDontSee('Loading DigitalOcean details...')
|
||||
->assertDontSee('Select a saved token')
|
||||
->assertDontSee('You need a saved')
|
||||
->assertDontSee('Continue');
|
||||
})->with([
|
||||
'hetzner' => [ByHetzner::class, 'hetzner', 'hetzner', 'Production Hetzner'],
|
||||
'vultr' => [ByVultr::class, 'vultr', 'vultr', 'Production Vultr'],
|
||||
'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean', 'Production DigitalOcean'],
|
||||
]);
|
||||
|
||||
test('provider token selection shows an add token box when no tokens exist', function (string $component, string $title, string $description) {
|
||||
Livewire::test($component)
|
||||
->assertSee($title)
|
||||
->assertSee($description)
|
||||
->assertSee('max-w-2xl', false)
|
||||
->assertSee('coolbox', false)
|
||||
->assertSee('M12 4.5v15m7.5-7.5h-15', false)
|
||||
->assertDontSee('wire:click="selectToken(', false);
|
||||
})->with([
|
||||
'hetzner' => [ByHetzner::class, 'Add a new token', 'Add a Hetzner API token to create servers from your account.'],
|
||||
'vultr' => [ByVultr::class, 'Add a new token', 'Add a Vultr API token to create servers from your account.'],
|
||||
'digital-ocean' => [ByDigitalOcean::class, 'Add a new token', 'Add a DigitalOcean API token to create Droplets from your account.'],
|
||||
]);
|
||||
|
||||
test('provider token urls pass the token uuid into the server creation page', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
]);
|
||||
|
||||
expect(route('server.create.token', [
|
||||
'type' => 'hetzner',
|
||||
'token_uuid' => $token->uuid,
|
||||
]))->toEndWith('/servers/new/hetzner/'.$token->uuid);
|
||||
|
||||
Livewire::test(CreatePage::class, [
|
||||
'type' => 'hetzner',
|
||||
'token_uuid' => $token->uuid,
|
||||
])
|
||||
->assertSet('type', 'hetzner')
|
||||
->assertSet('token_uuid', $token->uuid)
|
||||
->assertSet('title', 'Hetzner');
|
||||
});
|
||||
|
||||
test('provider token pages defer provider api data loading until wire init', function (string $component, string $provider, string $type, string $loadingText, string $wireInitCall) {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
Livewire::test($component, ['selectedTokenUuid' => $token->uuid])
|
||||
->assertSet('current_step', 2)
|
||||
->assertSet('loading_data', true)
|
||||
->assertSee($loadingText)
|
||||
->assertSee($wireInitCall, false)
|
||||
->assertSee('text-coollabs dark:text-warning animate-spin', false)
|
||||
->assertDontSee('border-b-2 border-primary', false);
|
||||
|
||||
Http::assertNothingSent();
|
||||
})->with([
|
||||
'hetzner' => [ByHetzner::class, 'hetzner', 'hetzner', 'Loading Hetzner data...', 'wire:init="loadHetznerData"'],
|
||||
'vultr' => [ByVultr::class, 'vultr', 'vultr', 'Loading Vultr data...', 'wire:init="loadVultrData"'],
|
||||
'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,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
Livewire::test(CreatePage::class, [
|
||||
'type' => $type,
|
||||
'token_uuid' => $token->uuid,
|
||||
])
|
||||
->assertSee(route('server.create.type', ['type' => $type]), false)
|
||||
->assertDontSee('href="'.route('server.create').'"', false);
|
||||
})->with([
|
||||
'hetzner' => ['hetzner', 'hetzner'],
|
||||
'vultr' => ['vultr', 'vultr'],
|
||||
'digital-ocean' => ['digitalocean', 'digital-ocean'],
|
||||
]);
|
||||
|
||||
test('new token header button is hidden on token specific provider creation pages', function (string $provider, string $type) {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
Livewire::test(CreatePage::class, [
|
||||
'type' => $type,
|
||||
])
|
||||
->assertSee('+ New Token');
|
||||
|
||||
Livewire::test(CreatePage::class, [
|
||||
'type' => $type,
|
||||
'token_uuid' => $token->uuid,
|
||||
])
|
||||
->assertDontSee('+ New Token');
|
||||
})->with([
|
||||
'hetzner' => ['hetzner', 'hetzner'],
|
||||
'vultr' => ['vultr', 'vultr'],
|
||||
'digital-ocean' => ['digitalocean', 'digital-ocean'],
|
||||
]);
|
||||
100
tests/Feature/ServerPrivateKeyDropdownTest.php
Normal file
100
tests/Feature/ServerPrivateKeyDropdownTest.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\PrivateKey\Show;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
Once::flush();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->actingAs($this->user);
|
||||
|
||||
Config::set('cache.default', 'array');
|
||||
Storage::fake('ssh-keys');
|
||||
|
||||
$this->currentPrivateKey = PrivateKey::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->currentPrivateKey->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('server private key page shows highlighted add dropdown actions', function () {
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSee('+ Add')
|
||||
->assertSee('Generate ED25519')
|
||||
->assertSee('Generate RSA')
|
||||
->assertSee('Add manually')
|
||||
->assertSee('Check connection');
|
||||
});
|
||||
|
||||
test('generating a server private key stores it and refreshes the current view', function () {
|
||||
$component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('generatePrivateKey', 'ed25519')
|
||||
->assertNoRedirect()
|
||||
->assertDispatched('success');
|
||||
|
||||
$privateKey = PrivateKey::query()
|
||||
->where('id', '!=', $this->currentPrivateKey->id)
|
||||
->firstOrFail();
|
||||
|
||||
expect($privateKey->team_id)->toBe($this->team->id)
|
||||
->and($privateKey->public_key)->toStartWith('ssh-ed25519');
|
||||
|
||||
$component
|
||||
->assertDispatched('copyPublicKeyToClipboard', publicKey: $privateKey->public_key)
|
||||
->assertSee($privateKey->name);
|
||||
});
|
||||
|
||||
test('server private key page copies generated public keys and shows a copied hint', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/private-key/show.blade.php'));
|
||||
|
||||
expect($view)->toContain('copyPublicKeyToClipboard')
|
||||
->and($view)->toContain('navigator.clipboard.writeText')
|
||||
->and($view)->toContain('Public key copied to clipboard.');
|
||||
});
|
||||
|
||||
test('server private key cards include a copy public key button', function () {
|
||||
$keyData = PrivateKey::generateNewKeyPair('rsa');
|
||||
|
||||
PrivateKey::createAndStore([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Alternative SSH Key',
|
||||
'description' => 'Created by test',
|
||||
'private_key' => $keyData['private_key'],
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSee('Copy public key')
|
||||
->assertSee('Alternative SSH Key');
|
||||
|
||||
$view = file_get_contents(resource_path('views/livewire/server/private-key/show.blade.php'));
|
||||
|
||||
expect($view)->toContain('Copy public key')
|
||||
->and($view)->toContain('$private_key->public_key')
|
||||
->and($view)->toContain('Public key copied to clipboard.');
|
||||
});
|
||||
|
|
@ -75,6 +75,19 @@
|
|||
expect($first->keys()->all())->toBe($second->keys()->all());
|
||||
});
|
||||
|
||||
it('renders the shared loading indicator while resource choices load', function () {
|
||||
View::share('errors', new ViewErrorBag);
|
||||
|
||||
$view = $this->view('livewire.project.new.select', [
|
||||
'current_step' => 'type',
|
||||
'environments' => collect(),
|
||||
]);
|
||||
|
||||
$view->assertSee('Loading resources...', false);
|
||||
$view->assertSee('text-coollabs dark:text-warning animate-spin', false);
|
||||
$view->assertDontSee('<div x-show="loading">Loading...</div>', false);
|
||||
});
|
||||
|
||||
it('renders the service templates last updated hint placeholder', function () {
|
||||
View::share('errors', new ViewErrorBag);
|
||||
|
||||
|
|
|
|||
|
|
@ -102,9 +102,7 @@ function vultrLivewireTestPublicKey(): string
|
|||
], 202),
|
||||
]);
|
||||
|
||||
Livewire::test(ByVultr::class)
|
||||
->set('selected_token_id', $this->vultrToken->id)
|
||||
->call('nextStep')
|
||||
Livewire::test(ByVultr::class, ['selectedTokenUuid' => $this->vultrToken->uuid])
|
||||
->assertSet('current_step', 2)
|
||||
->set('server_name', 'test-vultr-server')
|
||||
->set('selected_region', 'ewr')
|
||||
|
|
@ -148,3 +146,22 @@ function vultrLivewireTestPublicKey(): string
|
|||
->call('submit')
|
||||
->assertHasErrors(['enable_ipv6']);
|
||||
});
|
||||
|
||||
it('uses the shared dropdown UI for advanced Vultr options', function () {
|
||||
Livewire::test(ByVultr::class)
|
||||
->set('current_step', 2)
|
||||
->assertSee('Advanced Vultr options')
|
||||
->assertSeeHtml('dropdownOpen')
|
||||
->assertSeeHtml('x-ref="panel"')
|
||||
->assertSee('Additional SSH Keys (from Vultr)')
|
||||
->assertSee('Network Configuration')
|
||||
->assertSee('Cloud-Init Script');
|
||||
});
|
||||
|
||||
it('renders only the full width buy button at the bottom of the Vultr form', function () {
|
||||
Livewire::test(ByVultr::class)
|
||||
->set('current_step', 2)
|
||||
->assertDontSee('wire:click="previousStep"', false)
|
||||
->assertSeeHtml('class="button w-full"')
|
||||
->assertSee('Buy & Create Server', false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,11 +6,61 @@
|
|||
|
||||
expect($view)->toContain('<x-forms.select required id="provider" label="Provider" wire:model.live="provider">')
|
||||
->and($view)->toContain('<option value="vultr">Vultr</option>')
|
||||
->and($view)->toContain('<option disabled value="digitalocean">DigitalOcean</option>')
|
||||
->and($view)->toContain('<option value="digitalocean">DigitalOcean</option>')
|
||||
->and(substr_count($view, 'Open Account → API Access.'))->toBe(2)
|
||||
->and(substr_count($view, 'https://console.vultr.com/user/apiaccess/'))->toBe(2)
|
||||
->and($view)->not->toContain('<option value="digitalocean">DigitalOcean</option>')
|
||||
->and($view)->not->toContain('cloudProviderTokens->where(\'provider\', $provider)->isEmpty()')
|
||||
->and($view)->not->toContain('<x-forms.select required id="provider" label="Provider" disabled>')
|
||||
->and($component)->toContain("'provider' => 'required|string|in:hetzner,digitalocean,vultr'");
|
||||
});
|
||||
|
||||
it('keeps provider affiliate links on server provider views', function () {
|
||||
$tokenFormView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php');
|
||||
$hetznerView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-hetzner.blade.php');
|
||||
$vultrView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-vultr.blade.php');
|
||||
$digitalOceanView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-digital-ocean.blade.php');
|
||||
|
||||
expect($tokenFormView)
|
||||
->not->toContain('https://coolify.io/hetzner')
|
||||
->not->toContain('https://coolify.io/vultr')
|
||||
->and($hetznerView)->toContain('https://coolify.io/hetzner')
|
||||
->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" />');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,6 +33,19 @@
|
|||
->toContain('redirectRoute($this, \'project.resource.create\'');
|
||||
});
|
||||
|
||||
it('routes new server quick action to the new server page', function () {
|
||||
$globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php');
|
||||
$bladeFile = file_get_contents(__DIR__.'/../../resources/views/livewire/global-search.blade.php');
|
||||
|
||||
expect($globalSearchFile)
|
||||
->toContain("if (\$type === 'server')")
|
||||
->toContain("redirectRoute(\$this, 'server.create')");
|
||||
|
||||
expect($bladeFile)
|
||||
->toContain("window.location.href = '/servers/new'")
|
||||
->not->toContain('@open-create-modal-server.window');
|
||||
});
|
||||
|
||||
it('ensures docker-image item has quickcommand with new image', function () {
|
||||
$globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php');
|
||||
|
||||
|
|
@ -142,3 +155,13 @@
|
|||
|
||||
expect($globalSearchFile)->not->toContain("'logo_html' =>");
|
||||
});
|
||||
|
||||
it('uses rounded yellow plus icons for GlobalSearch creatable actions without logos', function () {
|
||||
$bladeFile = file_get_contents(__DIR__.'/../../resources/views/livewire/global-search.blade.php');
|
||||
|
||||
expect($bladeFile)
|
||||
->toContain('rounded-full bg-warning/20 flex items-center justify-center')
|
||||
->toContain('class="h-6 w-6 text-warning"')
|
||||
->not->toContain('rounded-lg bg-warning-100 dark:bg-warning-900/40')
|
||||
->not->toContain('text-warning-600 dark:text-warning-400');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue